The application surface is two annotations plus a tiny Navigation class — five static methods and one value type.

Declare a route

Annotate the target Form class:

@Route("/users/:id")
public class ProfileForm extends Form {
    public ProfileForm(@RouteParam("id") String id) {
        setTitle("Profile " + id);
        // null
    }
}

Or annotate a static factory method:

public class Routes {
    @Route("/home")
    public static Form home() {
        return new HomeForm();
    }

    @Route("/users/:id")
    public static Form profile(@RouteParam("id") String id) {
        return new ProfileForm(id);
    }
}

Both forms are supported and a project can mix them. Path variables in the pattern (:id, , *) are matched by name against parameters annotated with @RouteParam. Missing @RouteParam on a path-variable parameter fails the build.

Path syntax

PatternMatchesBound

/about

/about

 — 

/users/:id

/users/42

id = "42"

/files/*

/files/photo.png (one segment)

* = "photo.png"

/files/**

/files/a/b/c (catch-all)

* = "a/b/c"

Query string parameters are bound the same way as path variables. The build prefers a path-variable match for a given name and falls back to the query string when the pattern doesn’t include that name.

Navigate from app code

The Navigation class is the imperative counterpart to @Route. Calling Navigation.navigate("/users/42") from anywhere in the app resolves the URL through the same generated route table that handles incoming deep links, builds the matching Form, pushes it onto the navigation stack, and shows it.

The five-method surface (navigate, back, getCurrent, getStack, popTo) is everything the application sees — the rest of the URL-to- form machinery is generated. Applications that prefer raw new MyForm().show() keep working unchanged; only URL-driven calls update the Navigation stack.

How it works

Codename One scans the project’s compiled bytecode at build time and generates an internal dispatcher class from every @Route declaration. The dispatcher is bound into the per-platform application stub before Display.init(…​), so deep links delivered during launch see the route table immediately. The validation gate catches every problem in a single build run:

  • @Route declared on a class that doesn’t extend Form

  • Pattern with no leading / or empty value

  • Path variable with no matching @RouteParam on the constructor / factory

  • Duplicate pattern declared on two different targets

  • @Route on an abstract class or non-public static method

Host an apple-app-site-association JSON file at https://your.domain/.well-known/apple-app-site-association over HTTPS without redirects. The plugin’s AasaBuilder produces the payload:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.app"],
        "components": [
          { "/": "/users/*" },
          { "/": "/promo/*" }
        ]
      }
    ]
  }
}

Tell iOS which domains your app claims by setting the ios.associatedDomains build hint — a comma-separated list of applinks: entries. The iOS builder writes the Associated Domains entitlement so the resulting .ipa is signed for those domains automatically.

codename1.arg.ios.associatedDomains=applinks:example.com,applinks:www.example.com

Host an assetlinks.json file at https://your.domain/.well-known/assetlinks.json. The plugin’s AssetLinksBuilder produces the payload:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5", "7B:5A:1F:C9:0E:52:44:7C:82:19:6E:38:0A:B7:D4:11:5C:63:9F:28:E1:47:8B:30:A6:C2:5D:94:F0:6B:38:19"]
    }
  }
]

Tell Android which URLs to intercept by setting the android.xintent_filter build hint. The Android builder injects the value into the manifest verbatim, so it’s the whole <intent-filter> element, with \n for the line breaks:

codename1.arg.android.xintent_filter=<intent-filter android:autoVerify="true">\n  <action android:name="android.intent.action.VIEW"/>\n  <category android:name="android.intent.category.DEFAULT"/>\n  <category android:name="android.intent.category.BROWSABLE"/>\n  <data android:scheme="https" android:host="your.domain" android:pathPrefix="/users"/>\n</intent-filter>

Three parts of that carry weight. android:autoVerify="true" is what makes Android fetch the assetlinks.json above at install time and route the link straight into your app. Leaving it out doesn’t fall back to an app chooser: from Android 12 onward an unverified web link goes to the default browser, and your filter never sees it until the user turns the association on by hand under Settings > Apps > Open by default. The <data> element needs android:scheme="https" and the host, and narrowing it with android:pathPrefix keeps the app from claiming every URL on the domain. And the hint holds one value, so an app that also registers a custom scheme puts both <intent-filter> elements in the same string rather than setting the hint twice.

The SHA-256 fingerprint comes from keytool -list -v -keystore …​, or from the Play Console under Setup > App integrity when using Play App Signing.

Testing

iOS Simulator

xcrun simctl openurl booted "https://example.com/users/42"

Android Emulator

adb shell am start -a android.intent.action.VIEW \
    -d "https://example.com/users/42" com.example.app

Desktop Simulator

Pass --cn1-arg=https://example.com/users/42 on the run command, or use the simulator’s URL-injection helper.

Back-press guard

Independent of deep linking, individual forms can intercept back / pop attempts so they can confirm before discarding unsaved work:

editForm.setPopGuard(new PopGuard() {
    public boolean canPop(Form form, PopReason reason) {
        if (!isDirty()) {
            return true;
        }
        // Discard is the ok command, so it returns true and the pop
        // proceeds. The back key fires the cancel command, which means
        // dismissing this dialog keeps the user on the form.
        return Dialog.show("Discard changes?", "You have unsaved edits.",
                           "Discard", "Stay");
    }
});

The guard fires for the toolbar back button, the Android hardware back key, the iOS edge-swipe gesture, and any programmatic back navigation. PopReason distinguishes the trigger so a guard can be selective.