diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef8215..31fa648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: - name: Typecheck files run: yarn typecheck + - name: Test Expo config plugin + run: yarn test:plugin build-library: runs-on: ubuntu-latest @@ -73,6 +75,16 @@ jobs: npm install npm install --ignore-scripts "$GITHUB_WORKSPACE/$PACKAGE_TGZ" + node -e ' + const fs = require("node:fs"); + const app = JSON.parse(fs.readFileSync("app.json", "utf8")); + app.expo.plugins = [ + ...(app.expo.plugins || []), + ["@preeternal/react-native-cookie-manager", { androidWebkitVersion: "1.15.0" }] + ]; + fs.writeFileSync("app.json", JSON.stringify(app, null, 2) + "\n"); + ' + CI=1 npx expo config --json > "$EXPO_WORK_DIR/expo-config.json" 2> "$EXPO_WORK_DIR/expo-config.stderr.log" || { echo "::error::expo config failed" if [[ -f "$EXPO_WORK_DIR/expo-config.stderr.log" ]]; then @@ -91,6 +103,8 @@ jobs: CI=1 npx expo prebuild --platform android --clean --no-install 2>&1 | tee "$EXPO_WORK_DIR/prebuild.log" + grep -Fx "react_native_cookie_manager_webkit_version=1.15.0" android/gradle.properties + - name: Print Expo prebuild diagnostics if: always() run: | @@ -158,20 +172,16 @@ jobs: fi - name: Install JDK - if: env.turbo_cache_hit != 1 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: distribution: 'zulu' java-version: '17' - name: Finalize Android SDK - if: env.turbo_cache_hit != 1 run: | /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" - - name: Cache Gradle - if: env.turbo_cache_hit != 1 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: | @@ -181,6 +191,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- + - name: Run Android unit tests + working-directory: example/android + run: | + ./gradlew :preeternal_react-native-cookie-manager:testDebugUnitTest --no-daemon --console=plain + - name: Build example for Android env: JAVA_OPTS: "-XX:MaxHeapSize=6g" @@ -203,12 +218,6 @@ jobs: - name: Setup uses: ./.github/actions/setup - - name: Run Swift domain tests - run: | - CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/clang-module-cache" \ - SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/swift-module-cache" \ - swift test - - name: Cache turborepo for iOS uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: @@ -226,11 +235,36 @@ jobs: fi - name: Use appropriate Xcode version - if: env.turbo_cache_hit != 1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 with: xcode-version: ${{ env.XCODE_VERSION }} + - name: Run Swift tests + run: | + CLANG_MODULE_CACHE_PATH="$RUNNER_TEMP/clang-module-cache" \ + SWIFT_MODULECACHE_PATH="$RUNNER_TEMP/swift-module-cache" \ + swift test + + - name: Run iOS simulator tests + run: | + set -euo pipefail + + SIMULATOR_UDID=$(xcrun simctl list devices available --json | ruby -rjson -e ' + data = JSON.parse(STDIN.read) + device = data.fetch("devices").values.flatten.find do |item| + item["isAvailable"] && item["name"].start_with?("iPhone") + end + abort "No available iPhone simulator" unless device + puts device.fetch("udid") + ') + + xcodebuild \ + -scheme CookieManagerSwiftTests-Package \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath "$RUNNER_TEMP/swift-package-ios" \ + -quiet \ + test + - name: Install cocoapods if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' run: | diff --git a/.gitignore b/.gitignore index 843e1ba..b5ad2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +# Local notes +/docs/local-*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a007ebf..e841324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ --- +## v6.4.0: Reliable native cookies and modern attributes (Unreleased) + +### Added + +- Added `clearAllStores()` for deterministic full-store cleanup. It clears Foundation and the default WebKit store on iOS, or the shared cookie store on Android, and resolves only after native cleanup (including Android persistence) completes. Existing `clearAll(useWebKit?)` behavior is unchanged. +- Added `getAsArray()` on both platforms and iOS-only `getAllAsArray()` to preserve cookies that share a name but differ by domain or path. Existing `get()` and `getAll()` retain their upstream-compatible last-cookie-wins object shape. +- Added `getCookieHeader(url, useWebKit?)` to read matching cookies as a ready-to-use `Cookie` request-header value. It preserves duplicate names and returns an empty string when no cookies match. +- Added Android support for `clearByName(url, name)`. When the device's Android System WebView provider supports `GET_COOKIE_INFO`, it expires every same-name domain/path variant applicable to the supplied URL, waits for all deletion callbacks, then flushes persistence. Devices with an older provider reject with `not_supported` instead of risking incomplete deletion. +- Added AndroidX WebKit version configuration for bare React Native and Expo builds through `react_native_cookie_manager_webkit_version` / `androidWebkitVersion`. The shared `rootProject.ext.webkitVersion` override used by `react-native-webview` is also honored. +- Added structured `sameSite` and `maxAge` fields to `set()` on both platforms. `maxAge` is a relative lifetime in whole seconds and takes precedence over `expires`; `SameSite=None` requires `Secure`. Reads return `sameSite` when the selected native store exposes it and continue to return the effective absolute `expires` date rather than the original `maxAge`. + +### Fixed + +- Android `getFromResponse()` now performs the inherited GET request instead of returning the input URL. +- Android applies `Set-Cookie` before each redirect and selects stored cookies for every destination URL instead of forwarding the original `Cookie` header. +- Both platforms return the declared `Cookies` object shape and update their native cookie store. +- On iOS, `getFromResponse()` values are now `Cookie` objects instead of raw strings; read the cookie value through `.value`. This aligns runtime behavior with the existing TypeScript contract. +- Awaiting Android `getFromResponse()` now guarantees that cookies stored from the final response or any redirect have been flushed to persistent storage; the blocking persistence work runs on a worker thread. +- Awaiting Android `set()`, `setFromResponse()`, or `clearAll()` now guarantees that its automatic `flush()` has completed, so an immediate app shutdown or restart cannot leave the previous cookie state on disk. The blocking persistence work runs on a worker thread. +- Awaiting iOS `clearByName(url, name, true)` now waits for every matching WebKit deletion, preventing a following request or WebView load from observing cookies that were still being removed. +- iOS `get(url, true)` and `clearByName()` now match cookie domains case-insensitively in both Foundation and WebKit flows. Leading-dot domains apply to their root host as well as subdomains, while strict domain boundaries remain enforced. +- Android `get(url)` now returns stored `domain`, `path`, `expires`, `secure`, `httpOnly`, and `sameSite` attributes when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider transparently retain the previous name/value-only behavior. +- Android `set()` now serializes `expires` directly as an absolute `Expires` attribute instead of temporarily storing an absolute timestamp in `HttpCookie.maxAge`. Its ISO parser now accepts the `Z` and offset forms returned by JavaScript dates on Android instead of silently treating them as session cookies. This preserves future and past-date behavior while allowing the new relative `maxAge` field to use its standards-defined seconds contract without accidentally turning deletion cookies into session cookies. +- `removeSessionCookies()` now works on iOS and resolves only after session cookies have been removed from the selected stores; persistent cookies remain untouched. Foundation and the default WebKit store are selected by default. On Android, awaiting it now also guarantees that its automatic `flush()` has completed, so an immediate shutdown cannot leave removed session cookies on disk. +- iOS cookie cleanup no longer invokes the unrelated deprecated `UserDefaults.synchronize()` API. + +### Tests + +- Added Android unit coverage for the detailed cookie read, both fallback paths, empty results, attribute parsing, expiration precedence/conversion, and legacy behavior. +- Added Swift coverage for Foundation-only, WebKit-only, and both-store session cleanup, persistent-cookie preservation, and asynchronous deletion completion ordering. +- Added Swift coverage for full-store cleanup ordering and WebKit completion. +- Added Swift regression coverage for mixed-case, leading-dot root, parent-domain, and substring-rejection matching. +- Added Swift and Kotlin regression coverage for duplicate-name array reads and legacy object collapsing. +- Added Foundation and WebKit store integration tests on macOS and iOS Simulator, covering duplicate-name round trips, store selection, and completion-aware deletion/cleanup. +- Added Swift coverage for request-header formatting and WebKit domain/path/secure/expiry matching, plus Android coverage for raw header passthrough and empty stores. +- Added Android coverage for host-only, domain, path, prefixed, and partitioned cookie deletion, unsupported providers, callback ordering, and rejected writes. +- Added unit coverage for the Expo config plugin and extended the Expo prebuild smoke test to verify its generated Gradle property. +- Added Swift and Kotlin coverage for `sameSite`, relative `maxAge`, `maxAge`/`expires` precedence, immediate deletion, invalid values, concrete ISO timestamps and time-zone offsets, Android RFC serialization, and Foundation/WebKit store round trips. +- Added example app device checks: a one-tap public API smoke test and a two-phase prepare → force-stop → verify flow for persistent-cookie restoration without an extra manual `flush()`. + +### Compatibility + +- Android defaults to `androidx.webkit:webkit:1.16.0`; applications may request another full version through the package Gradle/Expo option or shared `rootProject.ext.webkitVersion`. Versions below `1.6.0` are rejected, while normal Gradle conflict resolution may select a higher version. The library defaults remain `minSdk 24`, `compileSdk 36`, and AGP 8.7.2. + +### Deprecated + +- `getFromResponse()` is deprecated but retained for compatibility with `@react-native-cookies/cookies`; no removal version is scheduled. +- Prefer making requests with Fetch/Axios and reading the shared native store with `get(url)`. This avoids a duplicate request and leaves request configuration with the application. + +### Documentation + +- Documented the network and cookie-store side effects of the legacy API. +- Added a concise Fetch/Axios response flow and clarified the advanced `setFromResponse()` use case. +- Clarified that direct Android `flush()` calls are redundant after library mutations and are intended only as a persistence barrier for external shared-store changes. +- Documented native persistence and expiration behavior, including Android WebView session-cookie restoration and the decision not to maintain a separate automatic cookie backup. +- Documented AndroidX WebKit overrides for bare React Native, `react-native-webview` interoperability, and Expo prebuild. +- Documented modern cookie attributes, read-back limitations, and why `partitioned` is not exposed without a reliable top-level partition context. + +--- + ## v6.3.3: Strict cookie domain validation This release fixes inherited cookie domain validation behavior that accepted substring matches instead of RFC-style domain matches. diff --git a/Package.swift b/Package.swift index 0ca4496..1b47ff7 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "CookieManagerSwiftTests", platforms: [ + .iOS(.v15), .macOS(.v12), ], products: [], @@ -11,13 +12,149 @@ let package = Package( .target( name: "CookieDomainLogic", path: "ios", - exclude: ["CookieManager.h", "CookieManager.mm", "CookieManager.swift"], + exclude: [ + "CookieAttributeLogic.swift", + "CookieHeaderLogic.swift", + "CookieCollectionLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + "CookieSessionLogic.swift", + ], sources: ["CookieDomainLogic.swift"] ), + .target( + name: "CookieSessionLogic", + path: "ios", + exclude: [ + "CookieAttributeLogic.swift", + "CookieHeaderLogic.swift", + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieSessionLogic.swift"] + ), + .target( + name: "CookieStoreClearLogic", + path: "ios", + exclude: [ + "CookieAttributeLogic.swift", + "CookieHeaderLogic.swift", + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieStoreAccess.swift", + "CookieSessionLogic.swift", + ], + sources: ["CookieStoreClearLogic.swift"] + ), + .target( + name: "CookieCollectionLogic", + path: "ios", + exclude: [ + "CookieAttributeLogic.swift", + "CookieHeaderLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieCollectionLogic.swift"] + ), + .target( + name: "CookieStoreAccess", + path: "ios", + exclude: [ + "CookieAttributeLogic.swift", + "CookieHeaderLogic.swift", + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieStoreAccess.swift"] + ), + .target( + name: "CookieHeaderLogic", + path: "ios", + exclude: [ + "CookieAttributeLogic.swift", + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieHeaderLogic.swift"] + ), + .target( + name: "CookieAttributeLogic", + path: "ios", + exclude: [ + "CookieCollectionLogic.swift", + "CookieDomainLogic.swift", + "CookieHeaderLogic.swift", + "CookieManager.h", + "CookieManager.mm", + "CookieManager.swift", + "CookieSessionLogic.swift", + "CookieStoreAccess.swift", + "CookieStoreClearLogic.swift", + ], + sources: ["CookieAttributeLogic.swift"] + ), .testTarget( name: "CookieDomainLogicTests", dependencies: ["CookieDomainLogic"], path: "swift-tests/CookieDomainLogicTests" ), + .testTarget( + name: "CookieSessionLogicTests", + dependencies: ["CookieSessionLogic"], + path: "swift-tests/CookieSessionLogicTests" + ), + .testTarget( + name: "CookieStoreClearLogicTests", + dependencies: ["CookieStoreClearLogic"], + path: "swift-tests/CookieStoreClearLogicTests" + ), + .testTarget( + name: "CookieCollectionLogicTests", + dependencies: ["CookieCollectionLogic"], + path: "swift-tests/CookieCollectionLogicTests" + ), + .testTarget( + name: "CookieStoreAccessIntegrationTests", + dependencies: ["CookieStoreAccess"], + path: "swift-tests/CookieStoreAccessIntegrationTests" + ), + .testTarget( + name: "CookieHeaderLogicTests", + dependencies: ["CookieHeaderLogic"], + path: "swift-tests/CookieHeaderLogicTests" + ), + .testTarget( + name: "CookieAttributeLogicTests", + dependencies: ["CookieAttributeLogic"], + path: "swift-tests/CookieAttributeLogicTests" + ), ] ) diff --git a/README.md b/README.md index 5e2ac37..65d61c2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![npm version](https://img.shields.io/npm/v/@preeternal/react-native-cookie-manager.svg)](https://www.npmjs.com/package/@preeternal/react-native-cookie-manager) [![npm downloads](https://img.shields.io/npm/dm/@preeternal/react-native-cookie-manager.svg)](https://www.npmjs.com/package/@preeternal/react-native-cookie-manager) -A modern, New Architecture–ready Cookie Manager for React Native. This is a drop-in replacement for @react-native-cookies/cookies, rewritten with TypeScript, TurboModules, and platform-native implementations for iOS (Swift) and Android (Kotlin). +A modern, New Architecture–ready Cookie Manager for React Native. This is a drop-in replacement for `@react-native-cookies/cookies`, rewritten with TypeScript, TurboModules, and platform-native implementations for iOS (Swift) and Android (Kotlin). ## Upstream / credits @@ -36,73 +36,136 @@ cd ios && bundle exec pod install Supports both old (bridged) and New Architecture (TurboModule) builds out of the box. Works in bare RN apps and in Expo Dev Builds (custom native build). +### AndroidX WebKit version + +Android uses `androidx.webkit:webkit:1.16.0` by default. Most applications do not need to configure it. Bare React Native apps can override the requested version in `android/gradle.properties`: + +```properties +react_native_cookie_manager_webkit_version=1.16.0 +``` + +Alternatively, an existing shared override in the root `android/build.gradle` is honored, including when it also configures `react-native-webview`: + +```gradle +rootProject.ext.webkitVersion = "1.16.0" +``` + +The package-specific `gradle.properties` value takes precedence when both are present. + +Expo apps can configure the same property during prebuild: + +```json +{ + "expo": { + "plugins": [ + [ + "@preeternal/react-native-cookie-manager", + { "androidWebkitVersion": "1.16.0" } + ] + ] + } +} +``` + +Versions older than `1.6.0` are unsupported because the library compiles against `CookieManagerCompat.getCookieInfo()`. Gradle may select a higher compatible version when another dependency requires it. + ## Usage +### After a network request + +React Native stores response cookies automatically. Make the request with your HTTP client, then read cookies matching the URL: + ```ts import CookieManager from '@preeternal/react-native-cookie-manager'; -// Set a cookie -try { - await CookieManager.set('https://example.com', { - name: 'session', - value: 'abc123', - domain: 'example.com', - path: '/', - secure: true, - httpOnly: true, - }); - - // Set cookies from a Set-Cookie header - await CookieManager.setFromResponse( - 'https://example.com', - 'user_session=abcdefg; path=/; expires=Thu, 1 Jan 2030 00:00:00 -0000; secure; HttpOnly' - ); - - // Get cookies for a URL - const cookies = await CookieManager.get('https://example.com'); - - // iOS only: get all cookies - const allCookies = await CookieManager.getAll(); - - // Clear by name (iOS only) - await CookieManager.clearByName('https://example.com', 'session'); - - // Clear everything - await CookieManager.clearAll(); - - // Android only: persist to storage - await CookieManager.flush(); - - // Android only: remove session cookies (no expires) - await CookieManager.removeSessionCookies(); -} catch (err) { - console.warn('Cookie operation failed', err); -} +const url = 'https://example.com/login'; +await fetch(url); +// axios alternative: await axios(url); +const cookies = await CookieManager.get(url); ``` -## API (compatible with @react-native-cookies/cookies) +Standard React Native networking handles cookies by default. Credentials options are only needed if your client configuration explicitly disables cookie handling. `get()` only reads the native cookie store; it does not make a request. -- `set(url, cookie, useWebKit?)` -- `setFromResponse(url, cookieHeader)` -- `get(url, useWebKit?)` -- `getFromResponse(url)` -- `clearAll(useWebKit?)` -- `flush()` — Android -- `removeSessionCookies()` — Android -- `getAll(useWebKit?)` — iOS -- `clearByName(url, name, useWebKit?)` — iOS +If a custom client or Axios adapter does not use React Native's native cookie handling, `getCookieHeader(url)` returns a ready-to-use `Cookie` request-header value. Do not add it to standard Fetch/Axios requests, where native networking already attaches cookies. -`useWebKit` applies only to iOS (switches to `WKHTTPCookieStore`); on Android it is ignored because WebView and native share a single cookie store. +The upstream-compatible `getFromResponse(url)` remains available but is deprecated: it performs a separate GET, follows redirects, and updates the cookie store without options for headers, authentication, timeout, or cancellation. Prefer the flow above to avoid a duplicate request and its side effects. -### WebKit on iOS +### Manage the cookie store -- iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). -- Pass `useWebKit: true` when you need cookies to sync with WKWebView. For network-only flows, omit it to use `NSHTTPCookieStorage`. -- If your app mixes both (native requests and embedded web), call the same operation twice: once with `useWebKit: true` (for WKWebView), once with `useWebKit: false` (for URLSession). -- On Android the flag is ignored; WebView and native use the same store. +```ts +await CookieManager.set('https://example.com', { + name: 'session', + value: 'abc123', + domain: 'example.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, +}); -> [!WARNING] -> On Android, `react-native-webview`'s `incognito` mode currently clears the shared app-wide cookie store, including cookies used by React Native networking. Avoid it when your app relies on authenticated native requests. See [react-native-webview#3988](https://github.com/react-native-webview/react-native-webview/issues/3988). +const cookies = await CookieManager.get('https://example.com'); + +// Preserve cookies that share a name but differ by domain or path +const cookieVariants = await CookieManager.getAsArray('https://example.com'); + +// iOS only: get all cookies +const allCookies = await CookieManager.getAll(); + +// Clear cookies named "session" +await CookieManager.clearByName('https://example.com', 'session'); + +// Clear Foundation on iOS; clear the shared store on Android +await CookieManager.clearAll(); + +// Clear Foundation and the default WebKit store on iOS +await CookieManager.clearAllStores(); + +// Remove session cookies from both iOS stores; shared Android store +await CookieManager.removeSessionCookies(); + +// iOS: limit session cleanup to one store when needed +await CookieManager.removeSessionCookies({ iosCookieStore: 'webKit' }); +``` + +All methods return Promises and reject when an operation fails. + +### Import a Set-Cookie header + +`setFromResponse()` is an advanced API for importing a raw `Set-Cookie` header from a custom HTTP client that does not share React Native's cookie store. It is normally unnecessary with Fetch or Axios. Call it once for each `Set-Cookie` header value. + +```ts +await CookieManager.setFromResponse( + 'https://example.com', + 'session=abc123; Path=/; Secure; HttpOnly' +); +``` + +## API + +The public API remains compatible with `@react-native-cookies/cookies`. + +| Method | Platforms | Description | +| --- | --- | --- | +| **`set(url, cookie, useWebKit?)`**: `Promise` | iOS, Android | Stores a cookie, including `sameSite` and relative `maxAge`. On iOS, uses Foundation by default or default WebKit when `true`. | +| **`get(url, useWebKit?)`**: `Promise` | iOS, Android | Reads matching cookies without making a request. On iOS, uses Foundation by default or default WebKit when `true`. | +| **`getAsArray(url, useWebKit?)`**: `Promise>` | iOS, Android | Reads matching cookies without collapsing cookies that share a name. Store selection matches `get()`. | +| **`getCookieHeader(url, useWebKit?)`**: `Promise` | iOS, Android | Returns the selected store's matching cookies as a `Cookie` request-header value, or an empty string. | +| **`clearAll(useWebKit?)`**: `Promise` | iOS, Android | Clears the shared Android store. On iOS, clears Foundation by default or default WebKit when `true`. | +| **`clearAllStores()`**: `Promise` | iOS, Android | Clears the shared Android store, or Foundation and default WebKit on iOS; resolves `true` after native completion. | +| **`getAll(useWebKit?)`**: `Promise` | iOS | Reads Foundation by default or default WebKit when `true`. | +| **`getAllAsArray(useWebKit?)`**: `Promise>` | iOS | Reads the selected iOS store without collapsing cookies that share a name. | +| **`clearByName(url, name, useWebKit?)`**: `Promise` | iOS, Android | Clears same-name cookies from the selected iOS store, or variants applicable to `url` in the shared Android store. | +| **`flush()`**: `Promise` | iOS, Android | Explicit Android persistence barrier for external shared-store changes. Android mutations already flush automatically; this method is a no-op on iOS. | +| **`removeSessionCookies(options?)`**: `Promise` | iOS, Android | Removes cookies without an expiry date and reports whether any were removed; includes both iOS stores by default. | +| **`setFromResponse(url, cookieHeader)`**: `Promise` | iOS, Android | Imports one raw `Set-Cookie` header value; uses Foundation on iOS. | +| **`getFromResponse(url)`**: `Promise` | iOS, Android | Deprecated; performs a GET and updates Foundation on iOS. | + +`useWebKit` is available on `set()`, `get()`, `getAsArray()`, `getCookieHeader()`, `clearAll()`, `getAll()`, `getAllAsArray()`, and `clearByName()`. On iOS, omitted/`false` selects Foundation and `true` selects only the default WebKit store; it never combines them. On Android the flag is ignored because WebView and native share a single store. + +`removeSessionCookies()` clears both iOS stores by default. Pass `{ iosCookieStore: 'foundation' }` or `{ iosCookieStore: 'webKit' }` to limit cleanup to one store. Android ignores this iOS-only option. + +On Android, `clearByName()` relies on `GET_COOKIE_INFO` support in the device's Android System WebView provider. It rejects with `not_supported` on devices with an older provider. The method clears every same-name domain/path variant visible to the supplied URL. A cookie restricted to `/account` is not visible from a `/` URL, so use a matching path (and multiple calls for unrelated paths). On iOS, the method clears same-domain variants across all paths in the selected store. ### Cookie shape @@ -116,9 +179,49 @@ type Cookie = { expires?: string; // ISO 8601 string, e.g. 2015-05-30T12:30:00.00-05:00 secure?: boolean; httpOnly?: boolean; + sameSite?: 'lax' | 'strict' | 'none'; + maxAge?: number; // set() only: relative lifetime in whole seconds }; ``` +`maxAge` takes precedence over `expires`; `0` or a negative value expires the cookie immediately. Native stores expose the resulting absolute `expires` date when reading, not the original `maxAge`. `sameSite: 'none'` requires `secure: true`. The iOS `HTTPCookie` model represents this unrestricted policy as no explicit SameSite value, so reads may omit `sameSite` after setting `'none'`. + +`partitioned` is intentionally not a structured field: creating a partitioned cookie requires top-level site context that this API's cookie URL cannot express consistently across Android and iOS. Prefer receiving it from the server or setting it inside the relevant WebView context. + +`Cookies` is keyed by cookie name, so `get()` and `getAll()` retain only the last item when multiple cookies share a name. This legacy behavior is preserved for upstream compatibility. Use `getAsArray()` or `getAllAsArray()` when `domain`/`path` variants must remain separate. + +On Android, metadata is populated when the device's Android System WebView provider supports `GET_COOKIE_INFO`. Devices with an older provider fall back to legacy name/value parsing, so `domain`, `path`, `expires`, and `sameSite` may be unavailable, while `secure` and `httpOnly` should not be treated as authoritative. + +### WebKit on iOS + +- iOS has two stores: `NSHTTPCookieStorage` (used by URLSession) and `WKHTTPCookieStore` (used by WKWebView / `react-native-webview`). +- Pass `useWebKit: true` to operate on the default WKWebView cookie store. For network-only flows, omit it to use `NSHTTPCookieStorage`. +- To apply `set()` or `clearByName()` to both stores, call the method once with `useWebKit: false` and once with `true`. Reading both stores with `get()`, `getAsArray()`, `getCookieHeader()`, `getAll()`, or `getAllAsArray()` likewise requires two calls; results are returned separately and are not merged. +- `getCookieHeader(url, true)` filters default WebKit cookies by domain, path, `Secure`, and expiry. A URL alone cannot reproduce WebKit's `SameSite`, partition, or third-party request context, so do not treat it as the exact header of an embedded WebView request. +- Use `clearAllStores()` when logout must clear both app-accessible stores. The library cannot access a non-persistent or custom store owned by a specific WebView. +- On Android the flag is ignored; WebView and native use the same store. + +> [!WARNING] +> On Android, `react-native-webview`'s `incognito` mode currently clears the shared app-wide cookie store, including cookies used by React Native networking. Avoid it when your app relies on authenticated native requests. See [react-native-webview#3988](https://github.com/react-native-webview/react-native-webview/issues/3988). + +### Persistence and expiration + +- A cookie is persistent only when the server supplies `Expires`/`Max-Age`, or when `set()` receives `expires`/`maxAge`. Native stores enforce expiration; `flush()` does not extend a cookie's lifetime or turn a session cookie into a persistent one. +- On iOS, Foundation persistent cookies survive without a WebView. There is no public iOS flush API, so `flush()` is a no-op. +- On Android, the library automatically flushes the shared WebView cookie store after its mutations. Current WebView implementations may also restore session cookies—cookies without an expiry—after a process restart. + +A persistent cookie written with `useWebKit: true` survives process termination only if a normal, non-incognito WKWebView using the default data store was mounted before the process ended. A normally mounted `react-native-webview` satisfies this requirement; installing the package without mounting a WebView does not. + +After a cold start, mount the WebView before reading cookies from the previous app session with `get()`, `getAsArray()`, `getCookieHeader()`, `getAll()`, or `getAllAsArray()` using the WebKit store. Do the same before `clearByName(..., true)` when deleting a persistent cookie from the previous session, because this method first reads the store to find matching cookies. + +Full cleanup with `clearAll(true)` or `clearAllStores()` can run before a WebView is mounted because it clears WebKit website data directly. `removeSessionCookies()` can also run before mounting; iOS session cookies are process-scoped and are not expected to survive a restart. + +If the app never creates a WebView, use the Foundation store instead: omit `useWebKit` or pass `false`. + +On Android, mutation methods automatically flush before their Promises resolve. Calling `flush()` immediately after awaiting `set()`, `setFromResponse()`, `getFromResponse()`, `clearByName()`, `clearAll()`, `clearAllStores()`, or `removeSessionCookies()` is redundant. Use it only as an explicit persistence barrier after the shared Android store was changed outside this library. + +The library intentionally does not maintain a separate cookie backup or silently replay cookies on startup. That could resurrect expired or logged-out authentication state and would require the application to choose appropriate secure storage. Prefer server-defined persistent cookies; call `removeSessionCookies()` before the first request or WebView load when the application requires a clean session on launch. + ## Contributing - [Development workflow](CONTRIBUTING.md#development-workflow) diff --git a/android/build.gradle b/android/build.gradle index fbe828c..c424141 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,9 +1,10 @@ buildscript { - ext.CookieManager = [ + ext.CookieManagerConfig = [ kotlinVersion: "2.0.21", minSdkVersion: 24, compileSdkVersion: 36, - targetSdkVersion: 36 + targetSdkVersion: 36, + webkitVersion: "1.16.0" ] ext.getExtOrDefault = { prop -> @@ -11,7 +12,7 @@ buildscript { return rootProject.ext.get(prop) } - return CookieManager[prop] + return CookieManagerConfig[prop] } repositories { @@ -32,6 +33,39 @@ apply plugin: "kotlin-android" apply plugin: "com.facebook.react" +def configuredWebkitVersion = + project.findProperty("react_native_cookie_manager_webkit_version") + ?: rootProject.findProperty("react_native_cookie_manager_webkit_version") +def cookieManagerWebkitVersion = + (configuredWebkitVersion ?: getExtOrDefault("webkitVersion")).toString() +def webkitVersionMatch = + (cookieManagerWebkitVersion =~ /^(\d+)\.(\d+)\.(\d+)((?:[-+][0-9A-Za-z.-]+)?)$/) + +if (!webkitVersionMatch.matches()) { + throw new GradleException( + "react_native_cookie_manager_webkit_version must be a full version such as 1.16.0 " + + "(got '${cookieManagerWebkitVersion}')" + ) +} + +def webkitVersionParts = [ + webkitVersionMatch.group(1).toInteger(), + webkitVersionMatch.group(2).toInteger(), + webkitVersionMatch.group(3).toInteger() +] +def webkitVersionSuffix = webkitVersionMatch.group(4) +def webkitVersionIsTooOld = + webkitVersionParts[0] < 1 || + (webkitVersionParts[0] == 1 && webkitVersionParts[1] < 6) || + (webkitVersionParts == [1, 6, 0] && webkitVersionSuffix.startsWith("-")) + +if (webkitVersionIsTooOld) { + throw new GradleException( + "AndroidX WebKit ${cookieManagerWebkitVersion} is unsupported; " + + "@preeternal/react-native-cookie-manager requires 1.6.0 or newer" + ) +} + android { namespace "com.preeternal.reactnativecookiemanager" @@ -64,4 +98,7 @@ android { dependencies { implementation "com.facebook.react:react-android" + implementation "androidx.webkit:webkit:${cookieManagerWebkitVersion}" + + testImplementation "junit:junit:4.13.2" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index a2f47b6..f79cac1 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,2 +1,3 @@ + diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt new file mode 100644 index 0000000..10f0826 --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogic.kt @@ -0,0 +1,131 @@ +package com.preeternal.reactnativecookiemanager + +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +internal sealed interface CookieDeletionPlan { + data object Unsupported : CookieDeletionPlan + + data class Ready(val headers: List) : CookieDeletionPlan +} + +internal fun planCookieDeletion( + name: String, + supportsDetailedRead: Boolean, + detailedReader: () -> List +): CookieDeletionPlan { + if (!supportsDetailedRead) return CookieDeletionPlan.Unsupported + + val storedHeaders = try { + detailedReader() + } catch (_: UnsupportedOperationException) { + return CookieDeletionPlan.Unsupported + } + + return CookieDeletionPlan.Ready( + storedHeaders.mapNotNull { deletionHeader(it, name) } + ) +} + +internal fun executeCookieDeletion( + headers: List, + setter: (header: String, callback: (Boolean) -> Unit) -> Unit, + completion: (Result) -> Unit +) { + if (headers.isEmpty()) { + completion(Result.success(false)) + return + } + + val remaining = AtomicInteger(headers.size) + val completed = AtomicBoolean(false) + val failure = AtomicReference(null) + + fun finishOne() { + if (remaining.decrementAndGet() == 0 && completed.compareAndSet(false, true)) { + val error = failure.get() + if (error == null) { + completion(Result.success(true)) + } else { + completion(Result.failure(error)) + } + } + } + + for (header in headers) { + try { + setter(header) { accepted -> + if (!accepted) { + failure.compareAndSet( + null, + CookieDeletionException("Android System WebView provider rejected a cookie deletion") + ) + } + finishOne() + } + } catch (error: Exception) { + failure.compareAndSet(null, error) + finishOne() + } + } +} + +private fun deletionHeader(storedHeader: String, requestedName: String): String? { + val segments = storedHeader.split(';') + val nameValue = segments.firstOrNull()?.trim().orEmpty() + val separator = nameValue.indexOf('=') + if (separator <= 0) return null + + val storedName = nameValue.substring(0, separator).trim() + if (storedName != requestedName) return null + + val attributes = buildMap { + for (segment in segments.drop(1)) { + val attribute = segment.trim() + if (attribute.isEmpty()) continue + + val attributeSeparator = attribute.indexOf('=') + val key = if (attributeSeparator < 0) { + attribute + } else { + attribute.substring(0, attributeSeparator) + }.trim().lowercase(Locale.US) + val value = if (attributeSeparator < 0) { + null + } else { + attribute.substring(attributeSeparator + 1).trim() + } + put(key, value) + } + } + + val domain = attributes["domain"] + val path = attributes["path"] + require(!domain.isNullOrEmpty() && !path.isNullOrEmpty()) { + "GET_COOKIE_INFO returned a matching cookie without domain or path" + } + + return buildString { + append(storedName) + append("=; Path=") + append(path) + if (domain.startsWith('.')) { + append("; Domain=") + append(domain) + } + append("; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT") + if (attributes.containsKey("secure") || attributes.containsKey("partitioned")) { + append("; Secure") + } + if (attributes.containsKey("httponly")) { + append("; HttpOnly") + } + if (attributes.containsKey("partitioned")) { + append("; Partitioned") + } + } +} + +private class CookieDeletionException(message: String) : Exception(message) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt index e4df0ee..988d589 100644 --- a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieManagerModule.kt @@ -4,20 +4,30 @@ import android.os.Build import android.util.Log import android.webkit.CookieManager import android.webkit.ValueCallback +import androidx.webkit.CookieManagerCompat +import androidx.webkit.WebViewFeature import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule import java.lang.Exception import java.net.HttpCookie +import java.net.HttpURLConnection +import java.net.ProtocolException +import java.net.SocketTimeoutException import java.net.URL import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference @ReactModule(name = CookieManagerModule.NAME) class CookieManagerModule(reactContext: ReactApplicationContext) : @@ -32,17 +42,12 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : promise: Promise ) { val cookieString = try { - toRFC6265string(makeHTTPCookieObject(url, cookie)) + serializeCookieForSet(makeCookieSetData(url, cookie)) } catch (e: Exception) { promise.reject("cookie_set_error", e) return } - if (cookieString == null) { - promise.reject("invalid_cookie_values", INVALID_COOKIE_VALUES) - return - } - addCookies(url, cookieString, promise) } @@ -62,30 +67,127 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } try { - val cookiesString = getCookieManager().getCookie(url) - promise.resolve(createCookieList(cookiesString)) + promise.resolve(createCookieList(readCookies(url))) } catch (e: Exception) { promise.reject("get_cookie_error", e) } } + override fun getAsArray(url: String, useWebKit: Boolean?, promise: Promise) { + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + try { + promise.resolve(createCookieArray(readCookies(url))) + } catch (e: Exception) { + promise.reject("get_cookie_error", e) + } + } + + override fun getCookieHeader(url: String, useWebKit: Boolean?, promise: Promise) { + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + try { + promise.resolve(readCookieHeader(url) { getCookieManager().getCookie(it) }) + } catch (e: Exception) { + promise.reject("get_cookie_header_error", e) + } + } + override fun getFromResponse(url: String, promise: Promise) { - promise.resolve(url) + val parsedUrl = try { + URL(url) + } catch (e: Exception) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP, e) + return + } + + if (parsedUrl.host.isEmpty() || !isHttpScheme(parsedUrl.protocol)) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + NETWORK_EXECUTOR.execute { + fetchResponseCookies(parsedUrl, promise) + } } override fun getAll(useWebKit: Boolean?, promise: Promise) { promise.reject("not_supported", GET_ALL_NOT_SUPPORTED) } + override fun getAllAsArray(useWebKit: Boolean?, promise: Promise) { + promise.reject("not_supported", GET_ALL_NOT_SUPPORTED) + } + override fun clearByName(url: String, name: String, useWebKit: Boolean?, promise: Promise) { - promise.reject("not_supported", CLEAR_BY_NAME_NOT_SUPPORTED) + if (url.isEmpty()) { + promise.reject("invalid_url", INVALID_URL_MISSING_HTTP) + return + } + + val cookieManager = try { + getCookieManager() + } catch (e: Exception) { + promise.reject("clear_by_name_error", e) + return + } + + val plan = try { + planCookieDeletion( + name = name, + supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), + detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) } + ) + } catch (e: Exception) { + promise.reject("clear_by_name_error", e) + return + } + + when (plan) { + CookieDeletionPlan.Unsupported -> { + promise.reject("not_supported", CLEAR_BY_NAME_NOT_SUPPORTED) + } + is CookieDeletionPlan.Ready -> executeCookieDeletion( + headers = plan.headers, + setter = { header, callback -> + cookieManager.setCookie(url, header) { accepted -> callback(accepted == true) } + } + ) { result -> + result.fold( + onSuccess = { removed -> + if (removed) { + flushAndResolve(cookieManager, true, promise, "clear_by_name_error") + } else { + promise.resolve(false) + } + }, + onFailure = { error -> promise.reject("clear_by_name_error", error) } + ) + } + } } override fun clearAll(useWebKit: Boolean?, promise: Promise) { + clearAllCookies(promise, returnRemovalResult = true) + } + + override fun clearAllStores(promise: Promise) { + clearAllCookies(promise, returnRemovalResult = false) + } + + private fun clearAllCookies(promise: Promise, returnRemovalResult: Boolean) { try { val cookieManager = getCookieManager() - cookieManager.removeAllCookies { promise.resolve(it) } - cookieManager.flush() + cookieManager.removeAllCookies { removed -> + val result = if (returnRemovalResult) removed else true + flushAndResolve(cookieManager, result, promise, "clear_all_error") + } } catch (e: Exception) { promise.reject("clear_all_error", e) } @@ -100,9 +202,16 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : } } - override fun removeSessionCookies(promise: Promise) { + override fun removeSessionCookies( + iosClearFoundation: Boolean, + iosClearWebKit: Boolean, + promise: Promise + ) { try { - getCookieManager().removeSessionCookies { promise.resolve(it) } + val cookieManager = getCookieManager() + cookieManager.removeSessionCookies { + flushAndResolve(cookieManager, it, promise, "remove_session_error") + } } catch (e: Exception) { promise.reject("remove_session_error", e) } @@ -111,109 +220,385 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : private fun addCookies(url: String, cookieString: String, promise: Promise) { try { val cookieManager = getCookieManager() - cookieManager.setCookie(url, cookieString) { promise.resolve(it) } - cookieManager.flush() + cookieManager.setCookie(url, cookieString) { + flushAndResolve(cookieManager, it, promise, "add_cookie_error") + } } catch (e: Exception) { promise.reject("add_cookie_error", e) } } - private fun createCookieList(allCookies: String?): WritableMap { + private fun flushAndResolve( + cookieManager: CookieManager, + result: Any?, + promise: Promise, + errorCode: String + ) { + PERSISTENCE_EXECUTOR.execute { + try { + cookieManager.flush() + promise.resolve(result) + } catch (e: Exception) { + promise.reject(errorCode, e) + } + } + } + + private fun readCookies(url: String): List { + val cookieManager = getCookieManager() + val cookieHeaders = readCookieHeaders( + supportsDetailedRead = WebViewFeature.isFeatureSupported(WebViewFeature.GET_COOKIE_INFO), + detailedReader = { CookieManagerCompat.getCookieInfo(cookieManager, url) }, + legacyReader = { cookieManager.getCookie(url) } + ) + return parseCookieReadResult(cookieHeaders) + } + + private fun createCookieList(cookies: List): WritableMap { val allCookiesMap = Arguments.createMap() - if (!allCookies.isNullOrEmpty()) { - val cookieHeaders = allCookies.split(";") - for (singleCookie in cookieHeaders) { - val cookies = HttpCookie.parse(singleCookie) - for (cookie in cookies) { - if (cookie != null) { - val name = cookie.name - val value = cookie.value - if (!isEmpty(name) && !isEmpty(value)) { - val cookieMap = createCookieData(cookie) - allCookiesMap.putMap(name, cookieMap) + for (cookie in cookies) { + allCookiesMap.putMap(cookie.name, createCookieMap(cookie)) + } + + return allCookiesMap + } + + private fun createCookieArray(cookies: List): WritableArray { + val cookieArray = Arguments.createArray() + for (cookie in cookies) { + cookieArray.pushMap(createCookieMap(cookie)) + } + return cookieArray + } + + private fun createCookieMap(cookie: ParsedCookie): WritableMap { + val cookieMap = Arguments.createMap() + cookieMap.putString("name", cookie.name) + cookieMap.putString("value", cookie.value) + cookieMap.putString("domain", cookie.domain) + cookieMap.putString("path", cookie.path) + cookieMap.putBoolean("secure", cookie.secure) + cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.sameSite?.let { cookieMap.putString("sameSite", it) } + cookie.expiresAt?.let { expiresAt -> + formatDate(Date(expiresAt))?.let { expires -> + cookieMap.putString("expires", expires) + } + } + return cookieMap + } + + private fun fetchResponseCookies( + url: URL, + promise: Promise + ) { + var currentUrl = url + var redirectCount = 0 + var storedRedirectCookies = false + + try { + while (true) { + var connection: HttpURLConnection? = null + try { + connection = currentUrl.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.instanceFollowRedirects = false + connection.connectTimeout = FETCH_TIMEOUT_MILLISECONDS + connection.readTimeout = FETCH_TIMEOUT_MILLISECONDS + + val requestCookieHeader = getCookieManager().getCookie(currentUrl.toString()) + if (!requestCookieHeader.isNullOrEmpty()) { + connection.setRequestProperty("Cookie", requestCookieHeader) + } + + // Accessing the status code performs the request. HTTP error statuses + // are still valid responses and may contain Set-Cookie headers. + val responseCode = connection.responseCode + val setCookieHeaders = getSetCookieHeaders(connection) + val redirectUrl = getRedirectUrl(currentUrl, connection, responseCode) + + if (redirectUrl != null) { + if (redirectCount >= MAX_REDIRECTS) { + throw ProtocolException("Too many redirects: ${redirectCount + 1}") } + + // Apply redirect cookies before selecting cookies for the next URL. + // The callback confirms that WebView's cookie store has been updated. + storeResponseCookiesAndWait(currentUrl, setCookieHeaders) + storedRedirectCookies = storedRedirectCookies || setCookieHeaders.isNotEmpty() + currentUrl = redirectUrl + redirectCount += 1 + continue + } + + val responseCookies = parseResponseCookies(setCookieHeaders, currentUrl) + reactApplicationContext.runOnUiQueueThread { + storeResponseCookies( + currentUrl, + setCookieHeaders, + responseCookies, + shouldFlush = storedRedirectCookies || setCookieHeaders.isNotEmpty(), + promise = promise + ) } + return + } finally { + connection?.disconnect() } } + } catch (e: Exception) { + promise.reject("get_from_response_error", e) } - - return allCookiesMap } - @Throws(Exception::class) - private fun makeHTTPCookieObject(url: String, cookie: ReadableMap): HttpCookie { - val parsedUrl = try { - URL(url) - } catch (e: Exception) { - throw Exception(INVALID_URL_MISSING_HTTP) + private fun getSetCookieHeaders(connection: HttpURLConnection): List = + connection.headerFields + .filterKeys { key -> key != null && isSetCookieHeader(key) } + .values + .flatten() + + private fun getRedirectUrl( + currentUrl: URL, + connection: HttpURLConnection, + responseCode: Int + ): URL? { + if (!isRedirectStatus(responseCode)) { + return null } - val topLevelDomain = parsedUrl.host - if (isEmpty(topLevelDomain)) { - throw Exception(INVALID_URL_MISSING_HTTP) + val location = connection.getHeaderField("Location") ?: return null + val redirectUrl = URL(currentUrl, location) + if (redirectUrl.host.isEmpty() || !isHttpScheme(redirectUrl.protocol)) { + throw ProtocolException("Unsupported redirect URL: $redirectUrl") } + return redirectUrl + } - val cookieBuilder = HttpCookie(cookie.getString("name"), cookie.getString("value")) - if (cookie.hasKey("domain") && !isEmpty(cookie.getString("domain"))) { - var domain = cookie.getString("domain") - if (domain != null && domain.startsWith(".")) { - domain = domain.substring(1) + private fun storeResponseCookiesAndWait(url: URL, headers: List) { + if (headers.isEmpty()) { + return + } + + val completionLatch = CountDownLatch(headers.size) + val storeError = AtomicReference(null) + + reactApplicationContext.runOnUiQueueThread { + try { + val cookieManager = getCookieManager() + for (header in headers) { + try { + cookieManager.setCookie(url.toString(), header) { + completionLatch.countDown() + } + } catch (e: Exception) { + storeError.compareAndSet(null, e) + completionLatch.countDown() + } + } + } catch (e: Exception) { + storeError.compareAndSet(null, e) + while (completionLatch.count > 0) { + completionLatch.countDown() + } } + } - if (domain != null && !domainMatches(topLevelDomain, domain)) { - throw Exception(String.format(INVALID_DOMAINS, topLevelDomain, domain)) + if (!completionLatch.await(FETCH_TIMEOUT_MILLISECONDS.toLong(), TimeUnit.MILLISECONDS)) { + throw SocketTimeoutException("Timed out while storing redirect cookies") + } + storeError.get()?.let { throw it } + } + + private fun parseResponseCookies(headers: List, responseUrl: URL): List { + val parsedCookies = mutableListOf() + val parsedAt = System.currentTimeMillis() + val defaultPath = defaultCookiePath(responseUrl) + + for (header in headers) { + val sameSite = parseSameSiteAttribute(header) + val cookies = try { + HttpCookie.parse(header) + } catch (e: IllegalArgumentException) { + Log.i(NAME, e.message ?: "Unable to parse Set-Cookie header") + continue } - if (domain != null) { - cookieBuilder.domain = domain + for (cookie in cookies) { + val expiresAt = if (cookie.maxAge >= 0) { + try { + Math.addExact(parsedAt, Math.multiplyExact(cookie.maxAge, 1000L)) + } catch (_: ArithmeticException) { + null + } + } else { + null + } + + parsedCookies.add( + ResponseCookie( + name = cookie.name, + value = cookie.value, + domain = cookie.domain ?: responseUrl.host, + path = cookie.path ?: defaultPath, + version = cookie.version.toString(), + expiresAt = expiresAt, + secure = cookie.secure, + httpOnly = HTTP_ONLY_SUPPORTED && cookie.isHttpOnly, + sameSite = sameSite + ) + ) } - } else { - cookieBuilder.domain = topLevelDomain } - if (cookie.hasKey("path") && !isEmpty(cookie.getString("path"))) { - cookieBuilder.path = cookie.getString("path") + return parsedCookies + } + + private fun storeResponseCookies( + responseUrl: URL, + headers: List, + cookies: List, + shouldFlush: Boolean, + promise: Promise + ) { + val result = createResponseCookieList(cookies) + if (!shouldFlush) { + promise.resolve(result) + return } - if (cookie.hasKey("expires") && !isEmpty(cookie.getString("expires"))) { - val date = parseDate(cookie.getString("expires")) - if (date != null) { - cookieBuilder.maxAge = date.time + var settled = false + try { + val cookieManager = getCookieManager() + if (headers.isEmpty()) { + flushAndResolve(cookieManager, result, promise, "get_from_response_error") + return + } + + var remaining = headers.size + + for (header in headers) { + cookieManager.setCookie(responseUrl.toString(), header) { + remaining -= 1 + if (remaining == 0 && !settled) { + settled = true + flushAndResolve(cookieManager, result, promise, "get_from_response_error") + } + } + } + } catch (e: Exception) { + if (!settled) { + settled = true + promise.reject("get_from_response_error", e) } } + } - if (cookie.hasKey("secure") && cookie.getBoolean("secure")) { - cookieBuilder.secure = true + private fun createResponseCookieList(cookies: List): WritableMap { + val result = Arguments.createMap() + + for (cookie in cookies) { + val cookieMap = Arguments.createMap() + cookieMap.putString("name", cookie.name) + cookieMap.putString("value", cookie.value) + cookieMap.putString("domain", cookie.domain) + cookieMap.putString("path", cookie.path) + cookieMap.putString("version", cookie.version) + cookieMap.putBoolean("secure", cookie.secure) + cookieMap.putBoolean("httpOnly", cookie.httpOnly) + cookie.sameSite?.let { cookieMap.putString("sameSite", it) } + cookie.expiresAt?.let { expiresAt -> + formatDate(Date(expiresAt))?.let { expires -> + cookieMap.putString("expires", expires) + } + } + result.putMap(cookie.name, cookieMap) } - if (HTTP_ONLY_SUPPORTED && cookie.hasKey("httpOnly") && cookie.getBoolean("httpOnly")) { - cookieBuilder.isHttpOnly = true + return result + } + + private fun defaultCookiePath(url: URL): String { + val path = url.path + if (path.isNullOrEmpty() || !path.startsWith('/')) { + return "/" } - return cookieBuilder + val lastSlash = path.lastIndexOf('/') + return if (lastSlash <= 0) "/" else path.substring(0, lastSlash) } - private fun createCookieData(cookie: HttpCookie): WritableMap { - val cookieMap = Arguments.createMap() - cookieMap.putString("name", cookie.name) - cookieMap.putString("value", cookie.value) - cookieMap.putString("domain", cookie.domain) - cookieMap.putString("path", cookie.path) - cookieMap.putBoolean("secure", cookie.secure) - if (HTTP_ONLY_SUPPORTED) { - cookieMap.putBoolean("httpOnly", cookie.isHttpOnly) + @Throws(Exception::class) + private fun makeCookieSetData(url: String, cookie: ReadableMap): CookieSetData { + val parsedUrl = try { + URL(url) + } catch (e: Exception) { + throw Exception(INVALID_URL_MISSING_HTTP) } - val expires = cookie.maxAge - if (expires > 0) { - val expiry = formatDate(Date(expires)) - if (!isEmpty(expiry)) { - cookieMap.putString("expires", expiry) + val topLevelDomain = parsedUrl.host + if (isEmpty(topLevelDomain)) { + throw Exception(INVALID_URL_MISSING_HTTP) + } + + val validatedCookie = HttpCookie(cookie.getString("name"), cookie.getString("value")) + var domain: String? + if (cookie.hasKey("domain") && !isEmpty(cookie.getString("domain"))) { + domain = cookie.getString("domain") + if (domain != null && domain.startsWith(".")) { + domain = domain.substring(1) } + + if (domain != null && !domainMatches(topLevelDomain, domain)) { + throw Exception(String.format(INVALID_DOMAINS, topLevelDomain, domain)) + } + } else { + domain = topLevelDomain } - return cookieMap + val path = + if (cookie.hasKey("path") && !cookie.isNull("path")) { + cookie.getString("path")?.takeUnless { it.isEmpty() } + } else { + null + } + val secure = cookie.hasKey("secure") && cookie.getBoolean("secure") + val httpOnly = + HTTP_ONLY_SUPPORTED && cookie.hasKey("httpOnly") && cookie.getBoolean("httpOnly") + val maxAgeSeconds = + if (cookie.hasKey("maxAge") && !cookie.isNull("maxAge")) { + parseMaxAgeSeconds(cookie.getDouble("maxAge")) + } else { + null + } + val expiresAtMillis = + if ( + maxAgeSeconds == null && + cookie.hasKey("expires") && + !isEmpty(cookie.getString("expires")) + ) { + parseCookieExpires(cookie.getString("expires")) + } else { + null + } + val sameSite = + if (cookie.hasKey("sameSite") && !cookie.isNull("sameSite")) { + parseCookieSameSite(cookie.getString("sameSite") ?: "") + } else { + null + } + + return CookieSetData( + name = validatedCookie.name, + value = validatedCookie.value, + domain = domain, + path = path, + expiresAtMillis = expiresAtMillis, + maxAgeSeconds = maxAgeSeconds, + secure = secure, + httpOnly = httpOnly, + sameSite = sameSite + ) } private fun getCookieManager(): CookieManager { @@ -234,82 +619,66 @@ class CookieManagerModule(reactContext: ReactApplicationContext) : (normalizedHost == normalizedDomain || normalizedHost.endsWith(".$normalizedDomain")) } + private fun isHttpScheme(scheme: String): Boolean = + scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true) + + private fun isSetCookieHeader(name: String): Boolean = + name.equals("Set-Cookie", ignoreCase = true) || + name.equals("Set-Cookie2", ignoreCase = true) + + private fun isRedirectStatus(statusCode: Int): Boolean = + statusCode == HttpURLConnection.HTTP_MULT_CHOICE || + statusCode == HttpURLConnection.HTTP_MOVED_PERM || + statusCode == HttpURLConnection.HTTP_MOVED_TEMP || + statusCode == HttpURLConnection.HTTP_SEE_OTHER || + statusCode == HTTP_TEMPORARY_REDIRECT || + statusCode == HTTP_PERMANENT_REDIRECT + private fun dateFormatter(): DateFormat { val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", Locale.US) df.timeZone = TimeZone.getTimeZone("GMT") return df } - private fun rfc1123DateFormatter(): DateFormat { - val df = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US) - df.timeZone = TimeZone.getTimeZone("GMT") - return df - } - - private fun parseDate(dateString: String?, rfc1123: Boolean = false): Date? { - if (dateString.isNullOrEmpty()) return null + private fun formatDate(date: Date): String? { return try { - (if (rfc1123) rfc1123DateFormatter() else dateFormatter()).parse(dateString) - } catch (e: Exception) { - Log.i(NAME, e.message ?: "Unable to parse date") - null - } - } - - private fun formatDate(date: Date, rfc1123: Boolean = false): String? { - return try { - (if (rfc1123) rfc1123DateFormatter() else dateFormatter()).format(date) + dateFormatter().format(date) } catch (e: Exception) { Log.i(NAME, e.message ?: "Unable to format date") null } } - private fun toRFC6265string(cookie: HttpCookie?): String? { - if (cookie == null) return null - val builder = StringBuilder() - builder.append(cookie.name).append('=').append(cookie.value) - - if (!cookie.hasExpired()) { - val expiresAt = cookie.maxAge - if (expiresAt > 0) { - val dateString = formatDate(Date(expiresAt), true) - if (!isEmpty(dateString)) { - builder.append("; expires=").append(dateString) - } - } - } - - if (!isEmpty(cookie.domain)) { - builder.append("; domain=").append(cookie.domain) - } - - if (!isEmpty(cookie.path)) { - builder.append("; path=").append(cookie.path) - } - - if (cookie.secure) { - builder.append("; secure") - } - - if (HTTP_ONLY_SUPPORTED && cookie.isHttpOnly) { - builder.append("; httponly") - } - - return builder.toString() - } + private data class ResponseCookie( + val name: String, + val value: String, + val domain: String, + val path: String, + val version: String, + val expiresAt: Long?, + val secure: Boolean, + val httpOnly: Boolean, + val sameSite: String? + ) companion object { private const val INVALID_URL_MISSING_HTTP = "Invalid URL: It may be missing a protocol (ex. http:// or https://)." private const val INVALID_COOKIE_VALUES = "Unable to add cookie - invalid values" private const val GET_ALL_NOT_SUPPORTED = "Get all cookies not supported for Android (iOS only)" - private const val CLEAR_BY_NAME_NOT_SUPPORTED = "Cannot remove a single cookie by name on Android" + private const val CLEAR_BY_NAME_NOT_SUPPORTED = + "clearByName requires GET_COOKIE_INFO support from the device's Android System WebView provider" private const val INVALID_DOMAINS = "Cookie URL host %s and domain %s mismatched. The cookie won't set correctly." + private const val FETCH_TIMEOUT_MILLISECONDS = 60_000 + private const val MAX_REDIRECTS = 20 + private const val HTTP_TEMPORARY_REDIRECT = 307 + private const val HTTP_PERMANENT_REDIRECT = 308 private val USES_LEGACY_STORE = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP private val HTTP_ONLY_SUPPORTED = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + private val NETWORK_EXECUTOR = Executors.newCachedThreadPool() + private val PERSISTENCE_EXECUTOR = Executors.newSingleThreadExecutor() const val NAME = "CookieManager" } diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt new file mode 100644 index 0000000..3372dc3 --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieReadLogic.kt @@ -0,0 +1,126 @@ +package com.preeternal.reactnativecookiemanager + +import java.net.HttpCookie +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +internal sealed interface CookieReadResult { + data class Detailed(val headers: List) : CookieReadResult + + data class Legacy(val header: String?) : CookieReadResult +} + +internal data class ParsedCookie( + val name: String, + val value: String, + val domain: String?, + val path: String?, + val expiresAt: Long?, + val secure: Boolean, + val httpOnly: Boolean, + val sameSite: String? +) + +internal fun readCookieHeaders( + supportsDetailedRead: Boolean, + detailedReader: () -> List, + legacyReader: () -> String? +): CookieReadResult { + if (supportsDetailedRead) { + try { + return CookieReadResult.Detailed(detailedReader()) + } catch (_: UnsupportedOperationException) { + // The installed WebView is the final authority for feature support. + } + } + + return CookieReadResult.Legacy(legacyReader()) +} + +internal fun readCookieHeader( + url: String, + reader: (String) -> String? +): String = reader(url).orEmpty() + +internal fun parseCookieReadResult( + result: CookieReadResult, + parsedAtMillis: Long = System.currentTimeMillis() +): List { + val headers = when (result) { + is CookieReadResult.Detailed -> result.headers + is CookieReadResult.Legacy -> result.header?.split(';') ?: emptyList() + } + val hasAttributes = result is CookieReadResult.Detailed + + return headers.flatMap { header -> + if (header.isBlank()) { + emptyList() + } else { + HttpCookie.parse(header).mapNotNull { cookie -> + if (cookie.name.isEmpty() || cookie.value.isEmpty()) { + null + } else { + ParsedCookie( + name = cookie.name, + value = cookie.value, + domain = cookie.domain, + path = cookie.path, + expiresAt = if (hasAttributes) { + expirationTime(header, parsedAtMillis, cookie.maxAge) + } else { + null + }, + secure = cookie.secure, + httpOnly = cookie.isHttpOnly, + sameSite = if (hasAttributes) parseSameSiteAttribute(header) else null + ) + } + } + } + } +} + +internal fun parseSameSiteAttribute(header: String): String? = + SAME_SITE_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim()?.lowercase(Locale.US) + ?.takeIf { it == "lax" || it == "strict" || it == "none" } + +private fun expirationTime( + header: String, + parsedAtMillis: Long, + maxAgeSeconds: Long +): Long? { + val maxAgeAttribute = MAX_AGE_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim() + if (maxAgeAttribute?.toLongOrNull() == null) { + val expiresAttribute = EXPIRES_ATTRIBUTE.find(header)?.groupValues?.get(1)?.trim() + parseExpires(expiresAttribute)?.let { return it } + } + + if (maxAgeSeconds < 0) return null + + return try { + Math.addExact(parsedAtMillis, Math.multiplyExact(maxAgeSeconds, 1000L)) + } catch (_: ArithmeticException) { + null + } +} + +private fun parseExpires(value: String?): Long? { + if (value.isNullOrEmpty()) return null + + return try { + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("GMT") + }.parse(value)?.time + } catch (_: Exception) { + null + } +} + +private val MAX_AGE_ATTRIBUTE = + Regex("(?:^|;)\\s*max-age\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) +private val EXPIRES_ATTRIBUTE = + Regex("(?:^|;)\\s*expires\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) +private val SAME_SITE_ATTRIBUTE = + Regex("(?:^|;)\\s*samesite\\s*=\\s*([^;]*)", RegexOption.IGNORE_CASE) diff --git a/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt new file mode 100644 index 0000000..4029dfc --- /dev/null +++ b/android/src/main/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogic.kt @@ -0,0 +1,88 @@ +package com.preeternal.reactnativecookiemanager + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +internal enum class CookieSameSite(val attributeValue: String) { + LAX("Lax"), + STRICT("Strict"), + NONE("None") +} + +internal data class CookieSetData( + val name: String, + val value: String, + val domain: String?, + val path: String?, + val expiresAtMillis: Long?, + val maxAgeSeconds: Long?, + val secure: Boolean, + val httpOnly: Boolean, + val sameSite: CookieSameSite? +) + +internal fun parseCookieSameSite(value: String): CookieSameSite = + when (value.lowercase(Locale.US)) { + "lax" -> CookieSameSite.LAX + "strict" -> CookieSameSite.STRICT + "none" -> CookieSameSite.NONE + else -> throw IllegalArgumentException("sameSite must be \"lax\", \"strict\", or \"none\"") + } + +internal fun parseMaxAgeSeconds(value: Double): Long { + if (!value.isFinite() || value % 1.0 != 0.0 || kotlin.math.abs(value) > MAX_SAFE_INTEGER) { + throw IllegalArgumentException("maxAge must be a finite safe integer number of seconds") + } + return value.toLong() +} + +internal fun parseCookieExpires(value: String?): Long? { + if (value.isNullOrEmpty()) return null + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).apply { + isLenient = false + timeZone = TimeZone.getTimeZone("GMT") + }.parse(value)?.time + } catch (_: Exception) { + null + } +} + +internal fun serializeCookieForSet(cookie: CookieSetData): String { + if (cookie.sameSite == CookieSameSite.NONE && !cookie.secure) { + throw IllegalArgumentException("SameSite \"none\" requires secure: true") + } + + return buildString { + append(cookie.name).append('=').append(cookie.value) + + if (cookie.maxAgeSeconds != null) { + append("; Max-Age=").append(cookie.maxAgeSeconds) + } else if (cookie.expiresAtMillis != null) { + append("; Expires=").append(formatRfc1123(Date(cookie.expiresAtMillis))) + } + + if (!cookie.domain.isNullOrEmpty()) { + append("; Domain=").append(cookie.domain) + } + if (!cookie.path.isNullOrEmpty()) { + append("; Path=").append(cookie.path) + } + if (cookie.secure) { + append("; Secure") + } + if (cookie.httpOnly) { + append("; HttpOnly") + } + cookie.sameSite?.let { append("; SameSite=").append(it.attributeValue) } + } +} + +private fun formatRfc1123(date: Date): String = + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + }.format(date) + +private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt new file mode 100644 index 0000000..50c6c02 --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieDeletionLogicTest.kt @@ -0,0 +1,196 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CookieDeletionLogicTest { + @Test + fun reportsUnsupportedWithoutDetailedCookieInfo() { + var readerCalled = false + + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = false, + detailedReader = { + readerCalled = true + emptyList() + } + ) + + assertEquals(CookieDeletionPlan.Unsupported, plan) + assertFalse(readerCalled) + } + + @Test + fun reportsUnsupportedWhenProviderRejectsDetailedCookieInfo() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { throw UnsupportedOperationException("Not supported") } + ) + + assertEquals(CookieDeletionPlan.Unsupported, plan) + } + + @Test + fun createsExactHostOnlyAndDomainDeletionHeaders() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { + listOf( + "session=root; domain=www.example.com; path=/; secure; httponly", + "session=account; domain=.example.com; path=/account", + "theme=dark; domain=www.example.com; path=/" + ) + } + ) as CookieDeletionPlan.Ready + + assertEquals(2, plan.headers.size) + assertTrue(plan.headers[0].startsWith("session=; Path=/; Max-Age=0;")) + assertFalse(plan.headers[0].contains("Domain=")) + assertTrue(plan.headers[0].endsWith("; Secure; HttpOnly")) + assertTrue(plan.headers[1].contains("Path=/account; Domain=.example.com; Max-Age=0")) + } + + @Test + fun preservesAttributesRequiredToDeletePartitionedCookies() { + val plan = planCookieDeletion( + name = "partitioned", + supportsDetailedRead = true, + detailedReader = { + listOf( + "partitioned=value; domain=www.example.com; path=/; " + + "partitioned; samesite=none" + ) + } + ) as CookieDeletionPlan.Ready + + assertTrue(plan.headers.single().endsWith("; Secure; Partitioned")) + assertFalse(plan.headers.single().contains("SameSite")) + } + + @Test + fun preservesAttributesRequiredToDeleteHostPrefixedCookies() { + val plan = planCookieDeletion( + name = "__Host-session", + supportsDetailedRead = true, + detailedReader = { + listOf("__Host-session=value; domain=www.example.com; path=/; secure") + } + ) as CookieDeletionPlan.Ready + + val header = plan.headers.single() + assertTrue(header.startsWith("__Host-session=; Path=/;")) + assertFalse(header.contains("Domain=")) + assertTrue(header.endsWith("; Secure")) + } + + @Test + fun cookieNamesAreMatchedCaseSensitively() { + val plan = planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { + listOf("Session=value; domain=example.com; path=/") + } + ) as CookieDeletionPlan.Ready + + assertTrue(plan.headers.isEmpty()) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsMatchingDetailedCookieWithoutIdentityAttributes() { + planCookieDeletion( + name = "session", + supportsDetailedRead = true, + detailedReader = { listOf("session=value; secure") } + ) + } + + @Test + fun emptyPlanCompletesFalseWithoutCallingSetter() { + var setterCalled = false + var result: Result? = null + + executeCookieDeletion( + headers = emptyList(), + setter = { _, _ -> setterCalled = true }, + completion = { result = it } + ) + + assertFalse(setterCalled) + assertEquals(false, result?.getOrNull()) + } + + @Test + fun waitsForEveryDeletionCallback() { + val callbacks = mutableListOf<(Boolean) -> Unit>() + var result: Result? = null + + executeCookieDeletion( + headers = listOf("first", "second"), + setter = { _, callback -> callbacks.add(callback) }, + completion = { result = it } + ) + + assertEquals(2, callbacks.size) + assertEquals(null, result) + callbacks[1](true) + assertEquals(null, result) + callbacks[0](true) + assertEquals(true, result?.getOrNull()) + } + + @Test + fun reportsRejectedDeletionAfterAllCallbacks() { + val callbacks = mutableListOf<(Boolean) -> Unit>() + var result: Result? = null + + executeCookieDeletion( + headers = listOf("first", "second"), + setter = { _, callback -> callbacks.add(callback) }, + completion = { result = it } + ) + + callbacks[0](false) + assertEquals(null, result) + callbacks[1](true) + assertTrue(result?.isFailure == true) + } + + @Test + fun waitsForStartedCallbacksAfterSynchronousSetterFailure() { + var firstCallback: ((Boolean) -> Unit)? = null + var thirdCallback: ((Boolean) -> Unit)? = null + var completionCount = 0 + var result: Result? = null + var error: Throwable? = null + + executeCookieDeletion( + headers = listOf("first", "second", "third"), + setter = { header, callback -> + when (header) { + "first" -> firstCallback = callback + "second" -> throw IllegalStateException("failed") + "third" -> thirdCallback = callback + } + }, + completion = { + completionCount += 1 + result = it + error = it.exceptionOrNull() + } + ) + + assertEquals(null, result) + firstCallback?.invoke(true) + assertEquals(null, result) + thirdCallback?.invoke(true) + assertEquals(1, completionCount) + assertNotNull(error) + } +} diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt new file mode 100644 index 0000000..095836b --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieReadLogicTest.kt @@ -0,0 +1,198 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class CookieReadLogicTest { + @Test + fun returnsRawRequestCookieHeaderForUrl() { + var requestedUrl: String? = null + + val result = readCookieHeader("https://example.com/account") { url -> + requestedUrl = url + "session=root; session=account; theme=dark" + } + + assertEquals("https://example.com/account", requestedUrl) + assertEquals("session=root; session=account; theme=dark", result) + } + + @Test + fun returnsEmptyHeaderWhenStoreHasNoMatchingCookies() { + assertEquals("", readCookieHeader("https://example.com") { null }) + } + + @Test + fun usesDetailedReaderWhenSupported() { + var legacyReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { listOf("session=abc; domain=example.com; path=/") }, + legacyReader = { + legacyReaderCalled = true + "legacy=value" + } + ) + + assertEquals( + CookieReadResult.Detailed(listOf("session=abc; domain=example.com; path=/")), + result + ) + assertFalse(legacyReaderCalled) + } + + @Test + fun usesLegacyReaderWhenDetailedReadIsUnsupported() { + var detailedReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = false, + detailedReader = { + detailedReaderCalled = true + emptyList() + }, + legacyReader = { "session=abc; theme=dark" } + ) + + assertEquals(CookieReadResult.Legacy("session=abc; theme=dark"), result) + assertFalse(detailedReaderCalled) + } + + @Test + fun fallsBackWhenWebViewRejectsDetailedRead() { + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { throw UnsupportedOperationException("Not supported") }, + legacyReader = { "session=abc" } + ) + + assertEquals(CookieReadResult.Legacy("session=abc"), result) + } + + @Test + fun doesNotFallbackWhenDetailedReadReturnsNoCookies() { + var legacyReaderCalled = false + + val result = readCookieHeaders( + supportsDetailedRead = true, + detailedReader = { emptyList() }, + legacyReader = { + legacyReaderCalled = true + "legacy=value" + } + ) + + assertEquals(CookieReadResult.Detailed(emptyList()), result) + assertFalse(legacyReaderCalled) + } + + @Test + fun parsesDetailedCookieAttributes() { + val parsedAt = 1_700_000_000_000L + val result = CookieReadResult.Detailed( + listOf( + "session=abc; domain=.example.com; path=/account; max-age=3600; " + + "secure; httponly; partitioned; samesite=lax" + ) + ) + + val cookies = parseCookieReadResult(result, parsedAt) + + assertEquals(1, cookies.size) + assertEquals("session", cookies[0].name) + assertEquals("abc", cookies[0].value) + assertEquals(".example.com", cookies[0].domain) + assertEquals("/account", cookies[0].path) + assertEquals(parsedAt + 3_600_000L, cookies[0].expiresAt) + assertTrue(cookies[0].secure) + assertTrue(cookies[0].httpOnly) + assertEquals("lax", cookies[0].sameSite) + } + + @Test + fun convertsExpiresAttributeToAbsoluteTime() { + val parsedAt = System.currentTimeMillis() + val expectedExpiresAt = ((parsedAt + 3_600_000L) / 1000L) * 1000L + val formatter = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + val expires = formatter.format(Date(expectedExpiresAt)) + + val cookie = parseCookieReadResult( + CookieReadResult.Detailed( + listOf("session=abc; domain=example.com; path=/; expires=$expires") + ), + parsedAt + ).single() + + assertEquals(expectedExpiresAt, cookie.expiresAt) + } + + @Test + fun maxAgeTakesPrecedenceOverExpires() { + val parsedAt = 1_700_000_000_000L + val cookie = parseCookieReadResult( + CookieReadResult.Detailed( + listOf( + "session=abc; domain=example.com; path=/; max-age=60; " + + "expires=Wed, 09 Jun 2032 10:18:14 GMT" + ) + ), + parsedAt + ).single() + + assertEquals(parsedAt + 60_000L, cookie.expiresAt) + } + + @Test + fun legacyHeaderKeepsPreviousNameValueOnlyBehavior() { + val cookies = parseCookieReadResult( + CookieReadResult.Legacy("session=abc; theme=dark") + ) + + assertEquals(listOf("session", "theme"), cookies.map { it.name }) + assertEquals(listOf("abc", "dark"), cookies.map { it.value }) + for (cookie in cookies) { + assertNull(cookie.domain) + assertNull(cookie.path) + assertNull(cookie.expiresAt) + assertFalse(cookie.secure) + assertFalse(cookie.httpOnly) + assertNull(cookie.sameSite) + } + } + + @Test + fun normalizesSupportedSameSiteAndIgnoresUnknownValues() { + assertEquals("none", parseSameSiteAttribute("session=abc; SameSite=None; Secure")) + assertEquals("strict", parseSameSiteAttribute("session=abc; samesite=STRICT")) + assertNull(parseSameSiteAttribute("session=abc; SameSite=Cross-Site")) + } + + @Test + fun preservesDuplicateCookieNamesForArraySerialization() { + val detailedCookies = parseCookieReadResult( + CookieReadResult.Detailed( + listOf( + "session=root; domain=example.com; path=/", + "session=account; domain=example.com; path=/account" + ) + ) + ) + val legacyCookies = parseCookieReadResult( + CookieReadResult.Legacy("session=root; session=account") + ) + + assertEquals(listOf("root", "account"), detailedCookies.map { it.value }) + assertEquals(listOf("/", "/account"), detailedCookies.map { it.path }) + assertEquals(listOf("root", "account"), legacyCookies.map { it.value }) + } +} diff --git a/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt new file mode 100644 index 0000000..6eb354b --- /dev/null +++ b/android/src/test/java/com/preeternal/reactnativecookiemanager/CookieSerializationLogicTest.kt @@ -0,0 +1,141 @@ +package com.preeternal.reactnativecookiemanager + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class CookieSerializationLogicTest { + @Test + fun serializesExistingAttributesAndFutureExpiry() { + val result = serializeCookieForSet( + cookie(expiresAtMillis = 1_970_000_000_000L, secure = true, httpOnly = true) + ) + + assertEquals( + "session=value; Expires=Fri, 04 Jun 2032 22:13:20 GMT; " + + "Domain=example.com; Path=/; Secure; HttpOnly", + result + ) + } + + @Test + fun keepsPastExpiryAsADeletionInsteadOfCreatingASessionCookie() { + val result = serializeCookieForSet(cookie(expiresAtMillis = 1_000L)) + + assertEquals( + "session=value; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Domain=example.com; Path=/", + result + ) + } + + @Test + fun maxAgeUsesRelativeSecondsAndTakesPrecedenceOverExpires() { + val result = serializeCookieForSet( + cookie(expiresAtMillis = 1_970_000_000_000L, maxAgeSeconds = 60) + ) + + assertEquals( + "session=value; Max-Age=60; Domain=example.com; Path=/", + result + ) + } + + @Test + fun serializesImmediateAndPastMaxAgeDeletion() { + assertEquals( + "session=value; Max-Age=0; Domain=example.com; Path=/", + serializeCookieForSet(cookie(maxAgeSeconds = 0)) + ) + assertEquals( + "session=value; Max-Age=-1; Domain=example.com; Path=/", + serializeCookieForSet(cookie(maxAgeSeconds = -1)) + ) + } + + @Test + fun serializesModernSameSiteValues() { + assertEquals( + "session=value; Domain=example.com; Path=/; SameSite=Lax", + serializeCookieForSet(cookie(sameSite = CookieSameSite.LAX)) + ) + assertEquals( + "session=value; Domain=example.com; Path=/; Secure; SameSite=None", + serializeCookieForSet(cookie(secure = true, sameSite = CookieSameSite.NONE)) + ) + } + + @Test + fun rejectsInsecureSameSiteNone() { + assertThrows(IllegalArgumentException::class.java) { + serializeCookieForSet(cookie(sameSite = CookieSameSite.NONE)) + } + } + + @Test + fun parsesSameSiteCaseInsensitivelyAndRejectsUnknownValues() { + assertEquals(CookieSameSite.LAX, parseCookieSameSite("LAX")) + assertEquals(CookieSameSite.STRICT, parseCookieSameSite("Strict")) + assertEquals(CookieSameSite.NONE, parseCookieSameSite("none")) + assertThrows(IllegalArgumentException::class.java) { + parseCookieSameSite("cross-site") + } + } + + @Test + fun maxAgeRequiresAFiniteSafeInteger() { + assertEquals(60L, parseMaxAgeSeconds(60.0)) + assertEquals(-1L, parseMaxAgeSeconds(-1.0)) + for (value in listOf(1.5, Double.NaN, Double.POSITIVE_INFINITY, 9_007_199_254_740_992.0)) { + assertThrows(IllegalArgumentException::class.java) { + parseMaxAgeSeconds(value) + } + } + } + + @Test + fun parsesIsoExpiryOffsetsAndKeepsInvalidInputAsSessionOnly() { + val expected = 1_970_389_094_000L + + assertEquals( + expected, + parseCookieExpires("2032-06-09T10:18:14.000Z") + ) + assertEquals( + expected, + parseCookieExpires("2032-06-09T12:18:14.000+02:00") + ) + assertNull(parseCookieExpires("not-a-date")) + } + + @Test + fun preservesLegacyAttributeSegmentsInsideValue() { + val result = serializeCookieForSet( + cookie(value = "value; Priority=High") + ) + + assertEquals( + "session=value; Priority=High; Domain=example.com; Path=/", + result + ) + } + + private fun cookie( + value: String = "value", + expiresAtMillis: Long? = null, + maxAgeSeconds: Long? = null, + secure: Boolean = false, + httpOnly: Boolean = false, + sameSite: CookieSameSite? = null + ) = CookieSetData( + name = "session", + value = value, + domain = "example.com", + path = "/", + expiresAtMillis = expiresAtMillis, + maxAgeSeconds = maxAgeSeconds, + secure = secure, + httpOnly = httpOnly, + sameSite = sameSite + ) +} diff --git a/app.plugin.js b/app.plugin.js new file mode 100644 index 0000000..8d1e842 --- /dev/null +++ b/app.plugin.js @@ -0,0 +1,89 @@ +const pkg = require('./package.json'); + +let configPlugins; +try { + configPlugins = require('@expo/config-plugins'); +} catch (error) { + const errorCode = error && error.code; + if (errorCode !== 'MODULE_NOT_FOUND') { + throw error; + } + configPlugins = require('expo/config-plugins'); +} + +const { createRunOncePlugin } = configPlugins; +const withAndroidGradleProperties = + typeof configPlugins.withAndroidGradleProperties === 'function' + ? configPlugins.withAndroidGradleProperties + : configPlugins.withGradleProperties; + +if (typeof withAndroidGradleProperties !== 'function') { + throw new Error( + `${pkg.name}: incompatible Expo config-plugins API (missing Android Gradle properties helper).` + ); +} + +const PROPERTY_KEY = 'react_native_cookie_manager_webkit_version'; +const MINIMUM_VERSION = [1, 6, 0]; +const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)((?:[-+][0-9A-Za-z.-]+)?)$/; + +function normalizeAndroidWebkitVersion(rawVersion) { + if (rawVersion == null) { + return null; + } + + const version = String(rawVersion).trim(); + const match = VERSION_PATTERN.exec(version); + if (!match) { + throw new Error( + `${pkg.name}: invalid androidWebkitVersion '${rawVersion}'. Expected a full version such as '1.16.0'.` + ); + } + + const parts = match.slice(1, 4).map(Number); + const suffix = match[4]; + const isTooOld = + parts[0] < MINIMUM_VERSION[0] || + (parts[0] === MINIMUM_VERSION[0] && parts[1] < MINIMUM_VERSION[1]) || + (parts.every((part, index) => part === MINIMUM_VERSION[index]) && + suffix.startsWith('-')); + + if (isTooOld) { + throw new Error( + `${pkg.name}: androidWebkitVersion must be 1.6.0 or newer (got '${version}').` + ); + } + + return version; +} + +function withCookieManagerWebkitVersion(config, props = {}) { + const version = normalizeAndroidWebkitVersion(props.androidWebkitVersion); + if (version == null) { + return config; + } + + return withAndroidGradleProperties(config, (mod) => { + const existing = mod.modResults.find( + (item) => item.type === 'property' && item.key === PROPERTY_KEY + ); + + if (existing) { + existing.value = version; + } else { + mod.modResults.push({ + type: 'property', + key: PROPERTY_KEY, + value: version, + }); + } + + return mod; + }); +} + +module.exports = createRunOncePlugin( + withCookieManagerWebkitVersion, + pkg.name, + pkg.version +); diff --git a/app.plugin.test.js b/app.plugin.test.js new file mode 100644 index 0000000..6fc3fc5 --- /dev/null +++ b/app.plugin.test.js @@ -0,0 +1,74 @@ +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const test = require('node:test'); + +const originalLoad = Module._load; +Module._load = function loadWithConfigPluginsMock(request, parent, isMain) { + if (request === '@expo/config-plugins') { + return { + createRunOncePlugin: (plugin) => plugin, + withAndroidGradleProperties: (config, action) => { + const mod = { + modResults: config.androidGradleProperties || [], + }; + const result = action(mod); + config.androidGradleProperties = result.modResults; + return config; + }, + }; + } + + return originalLoad(request, parent, isMain); +}; + +let plugin; +try { + plugin = require('./app.plugin.js'); +} finally { + Module._load = originalLoad; +} + +test('does not write the default when the option is omitted', () => { + const config = {}; + + assert.equal(plugin(config), config); + assert.equal(config.androidGradleProperties, undefined); +}); + +test('adds and updates the AndroidX WebKit Gradle property', () => { + const config = {}; + + plugin(config, { androidWebkitVersion: '1.15.0' }); + plugin(config, { androidWebkitVersion: '1.16.0' }); + + assert.deepEqual(config.androidGradleProperties, [ + { + type: 'property', + key: 'react_native_cookie_manager_webkit_version', + value: '1.16.0', + }, + ]); +}); + +test('accepts AndroidX prerelease versions above the minimum', () => { + const config = {}; + + plugin(config, { androidWebkitVersion: '1.17.0-alpha01' }); + + assert.equal(config.androidGradleProperties[0].value, '1.17.0-alpha01'); +}); + +test('rejects malformed and unsupported versions', () => { + assert.throws( + () => plugin({}, { androidWebkitVersion: 'latest' }), + /Expected a full version/ + ); + assert.throws( + () => plugin({}, { androidWebkitVersion: '1.5.0' }), + /must be 1\.6\.0 or newer/ + ); + assert.throws( + () => plugin({}, { androidWebkitVersion: '1.6.0-alpha01' }), + /must be 1\.6\.0 or newer/ + ); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 8fa4a64..5f2fc0e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,14 @@ export default defineConfig([ }, }, { - ignores: ['eslint.config.mjs', 'node_modules/', 'lib/'], + ignores: [ + 'eslint.config.mjs', + 'node_modules/', + 'lib/', + 'android/build/', + 'example/android/build/', + 'example/android/app/build/', + 'example/ios/build/', + ], }, ]); diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 990d018..1176486 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - CookieManager (6.3.1): + - CookieManager (6.3.3): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2000,7 +2000,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - CookieManager: 4a8876c80ea83cfcae676429694c626413c9b399 + CookieManager: fee00e6371b9003d4c58d9b55fb2eea00540cd04 FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b hermes-engine: df3f092e2c5dcbf6704b61224f29edc0f9594aa1 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 diff --git a/example/metro.config.js b/example/metro.config.js index aa9a381..b3bc617 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -1,4 +1,5 @@ const path = require('path'); +const { URL } = require('url'); const { getDefaultConfig } = require('@react-native/metro-config'); const { withMetroConfig } = require('react-native-monorepo-config'); @@ -15,4 +16,39 @@ const config = withMetroConfig(getDefaultConfig(__dirname), { dirname: __dirname, }); +const defaultEnhanceMiddleware = config.server?.enhanceMiddleware; + +config.server = { + ...config.server, + enhanceMiddleware: (middleware, server) => { + const enhancedMiddleware = defaultEnhanceMiddleware + ? defaultEnhanceMiddleware(middleware, server) + : middleware; + + return (request, response, next) => { + const requestURL = new URL(request.url || '/', 'http://localhost'); + + if (requestURL.pathname === '/__cookie_manager_smoke__') { + const value = requestURL.searchParams.get('value'); + if (!value || !/^\d+$/.test(value)) { + response.statusCode = 400; + response.end('Expected a numeric value'); + return; + } + + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.setHeader( + 'Set-Cookie', + `cm_smoke_network=${value}; Path=/; Max-Age=300; SameSite=Lax` + ); + response.end(JSON.stringify({ ok: true })); + return; + } + + enhancedMiddleware(request, response, next); + }; + }, +}; + module.exports = config; diff --git a/example/src/App.tsx b/example/src/App.tsx index f83f305..c9fd763 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, - SafeAreaView, + Keyboard, + Platform, ScrollView, + StatusBar, StyleSheet, Text, TextInput, @@ -11,6 +13,12 @@ import { import CookieManager, { type Cookie, } from '@preeternal/react-native-cookie-manager'; +import { + preparePersistenceTest, + runDeviceSmokeTests, + type DeviceSmokeReport, + verifyPersistenceTest, +} from './deviceSmokeTests'; const DEFAULT_DOMAIN = '.example.com'; @@ -22,11 +30,68 @@ const buildUrlForDomain = (domain: string): string => { return `https://${host}`; }; +const reportSummary = (report: DeviceSmokeReport): string => { + const failed = report.checks.filter( + (check) => check.status === 'failed' + ).length; + const skipped = report.checks.filter( + (check) => check.status === 'skipped' + ).length; + + if (failed > 0) { + return `FAILED (${failed} failed)`; + } + + return skipped > 0 ? `PASSED (${skipped} skipped)` : 'PASSED'; +}; + +const logReport = (name: string, report: DeviceSmokeReport): void => { + console.info( + `[CookieManagerExample] ${name}`, + JSON.stringify(report, null, 2) + ); +}; + +const TestReport = ({ + report, + testID, +}: { + report: DeviceSmokeReport; + testID: string; +}) => ( + + + {reportSummary(report)} + + {report.checks.map((check) => ( + + {check.status === 'passed' + ? '✓' + : check.status === 'skipped' + ? '○' + : '✗'}{' '} + {check.name} + {check.detail ? ` — ${check.detail}` : ''} + + ))} + +); + export default function App() { const [domainInput, setDomainInput] = useState(DEFAULT_DOMAIN); const [output, setOutput] = useState('{}'); const [status, setStatus] = useState('Ready'); const [cookieIndex, setCookieIndex] = useState(1); + const [deviceReport, setDeviceReport] = useState( + null + ); + const [deviceTestsRunning, setDeviceTestsRunning] = useState(false); + const [persistenceReport, setPersistenceReport] = + useState(null); + const [persistenceStatus, setPersistenceStatus] = + useState('Start with step 1'); + const [persistenceTestRunning, setPersistenceTestRunning] = + useState(false); const inspectUrl = useMemo(() => { const normalizedDomain = normalizeDomainInput(domainInput); @@ -46,22 +111,24 @@ export default function App() { errors.sharedForUrl = String(error); } - try { - snapshot.webKitForUrl = await CookieManager.get(urlToInspect, true); - } catch (error) { - errors.webKitForUrl = String(error); - } + if (Platform.OS === 'ios') { + try { + snapshot.webKitForUrl = await CookieManager.get(urlToInspect, true); + } catch (error) { + errors.webKitForUrl = String(error); + } - try { - snapshot.allShared = await CookieManager.getAll(false); - } catch (error) { - errors.allShared = String(error); - } + try { + snapshot.allShared = await CookieManager.getAll(false); + } catch (error) { + errors.allShared = String(error); + } - try { - snapshot.allWebKit = await CookieManager.getAll(true); - } catch (error) { - errors.allWebKit = String(error); + try { + snapshot.allWebKit = await CookieManager.getAll(true); + } catch (error) { + errors.allWebKit = String(error); + } } setOutput(JSON.stringify({ ...snapshot, errors }, null, 2)); @@ -107,22 +174,14 @@ export default function App() { const clearCookies = useCallback(async () => { setStatus('Clearing cookies'); - const results = await Promise.allSettled([ - CookieManager.clearAll(false), - CookieManager.clearAll(true), - ]); - - const rejected = results.filter((result) => result.status === 'rejected'); - if (rejected.length > 0) { - setStatus(`Clear finished with errors (${rejected.length})`); - } else { - setStatus('All cookies cleared'); - } + await CookieManager.clearAllStores(); + setStatus('All cookies cleared'); await refreshCookies(inspectUrl); }, [inspectUrl, refreshCookies]); const handleAddCookiePress = useCallback(() => { + Keyboard.dismiss(); addCookieForDomain().catch((error) => { setStatus(`Failed to add cookie: ${String(error)}`); }); @@ -134,13 +193,87 @@ export default function App() { }); }, [inspectUrl, refreshCookies]); + const handleDeviceTestsPress = useCallback(() => { + setDeviceTestsRunning(true); + setDeviceReport(null); + setStatus('Running device smoke tests'); + + runDeviceSmokeTests() + .then((report) => { + logReport('device smoke test', report); + setDeviceReport(report); + setStatus( + report.passed + ? 'Device smoke tests PASSED' + : 'Device smoke tests FAILED' + ); + }) + .catch((error) => { + setStatus(`Device smoke tests failed: ${String(error)}`); + }) + .finally(() => { + setDeviceTestsRunning(false); + refreshCookies(inspectUrl).catch(() => undefined); + }); + }, [inspectUrl, refreshCookies]); + + const handlePreparePersistencePress = useCallback(() => { + setPersistenceTestRunning(true); + setPersistenceReport(null); + setPersistenceStatus('Preparing cookies'); + + preparePersistenceTest() + .then((report) => { + logReport('persistence prepare', report); + setPersistenceReport(report); + setPersistenceStatus( + report.passed + ? 'PREPARED — force-close the app now' + : 'Preparation FAILED — fix the failed checks and retry step 1' + ); + }) + .catch((error) => { + setPersistenceStatus(`Preparation failed: ${String(error)}`); + }) + .finally(() => { + setPersistenceTestRunning(false); + }); + }, []); + + const handleVerifyPersistencePress = useCallback(() => { + setPersistenceTestRunning(true); + setPersistenceReport(null); + setPersistenceStatus('Verifying restored cookies'); + + verifyPersistenceTest() + .then((report) => { + logReport('persistence verify', report); + setPersistenceReport(report); + setPersistenceStatus( + report.passed + ? 'Persistence test PASSED; test cookies were removed' + : 'Persistence test FAILED; state kept for inspection' + ); + }) + .catch((error) => { + setPersistenceStatus(`Verification failed: ${String(error)}`); + }) + .finally(() => { + setPersistenceTestRunning(false); + refreshCookies(inspectUrl).catch(() => undefined); + }); + }, [inspectUrl, refreshCookies]); + useEffect(() => { handleRefreshPress(); }, [handleRefreshPress]); return ( - - + + @preeternal/react-native-cookie-manager @@ -167,8 +300,72 @@ export default function App() { {output} + Native device smoke tests + + Clears this example app's cookie stores, exercises the public + API, then cleans up its test cookies. + +