Documentation Index

Fetch the complete documentation index at: https://academy.insiderone.com/llms.txt

Use this file to discover all available pages before exploring further.

Configure App Frames

Prev Next

Push notifications and in-app messages are powerful, but they are ephemeral or interruptive: a push disappears once it is tapped or dismissed, and an in-app message overlays whatever the user is doing. App Frames takes a different approach. It renders marketer-managed content inline, as a natural part of your screen, like a banner on the home page, a contextual offer on a product detail, a persistent announcement slot, rather than an overlay on top of it.

Your development team does the wiring once, enables the feature in the manifest, places an InsiderAppFramesView in the layout, and gives it a placement ID. From then on, you can create, target, and update the content in that placement entirely from Insider One's Action Builder, with no app release when the content changes.

App Frames are designed to fail silently. When a placement has no active campaign or its content cannot be loaded, the view renders nothing and measures to zero height. Your app keeps working normally, with no visible error and no empty box. Design your layout so a collapsed frame looks natural.

You can add an InsiderAppFramesView for each slot where inline content can appear and define your placements once during SDK integration. Then, you can run campaigns against those placements from Action Builder, choosing the design, audience, and schedule. The view automatically fetches and renders the campaign currently targeting its placement while it is attached to the window, and refreshes when conditions change; no manual load call exists. When no campaign is live, the view stays empty and collapsed.

Capabilities

  • Automatic refresh: The SDK refreshes App Frames content when the app comes to the foreground and when the user's identity changes, for example after login or logout. Transient network failures during the placements fetch are automatically retried with backoff.

  • In-memory caching: Rendered content is cached in memory for the lifetime of the app process, so returning to a screen re-renders instantly without a new fetch. Nothing is persisted to disk.

  • Automatic analytics: Impressions, clicks, and dismissals are logged by the SDK. There is no analytics API to call and nothing extra to track. An impression is recorded at the moment the template signals its content is on screen, and the frame has been laid out with a non-zero height, not merely when loading finishes off screen. If a new session opens while the frame is still on screen, a further impression is recorded for that session.

  • Marketer-side control: Marketers design content and target audiences, and run up to 8 variants per campaign from Action Builder, with campaign priority determining which variant wins placement.

With App Frames, you can create:

  • A promotional banner on the home screen.

  • A contextual offer on a product-detail screen.

  • A persistent announcement or messaging slot.

  • A seasonal campaign area that changes without an app release.

Requirements

Before getting started, it is important to understand the technical expectations for integrating App Frames.

  • Opt-in is required. App Frames stays completely dormant until you enable it in your AndroidManifest.xml. Without the flag, no placement is ever resolved, nothing renders, and no error is reported.

  • Fail-silent contract. An empty placement renders nothing and occupies no space. Never reserve fixed space for a frame; a placeholder that stays visible when no campaign is active shows the user an empty box.

  • Your app owns visibility. The SDK renders content into the view but never hides or removes it. It does not set View.GONE or View.INVISIBLE, and it does not detach the view from its parent. Showing, hiding, and dismissing the view are yours to control.

  • No manual load call. The view subscribes to its placement on its own and fetches content while it is attached to the window. You never trigger a load or refresh.

  • Main thread only. Create and configure the view on the main thread. Every listener callback is delivered on the main thread as well. Do not block it.

  • Ships inside the core SDK. App Frames is part of the core Insider SDK. There is no extra module or dependency to add.

  • Android API level 24+.

  • SDK must be initialized first. The Insider SDK must be integrated and initialized in your app before App Frames content can render.

  • User consent must be granted. GDPR consent (Insider.setGDPRConsent) and mobile app access (Insider.setMobileAppAccess) must both be true. When either is revoked, or the panel disables the SDK, frames report DISABLED and clear their content.

  • App Frames will not be displayed on devices with SDK versions older than the following versions:

    • Android 17.0.0

    • iOS 16.0.0

Integration

1. Enable App Frames

App Frames is off by default. Declare the opt-in flag under the <application> element of your AndroidManifest.xml:

<application ...>

    <meta-data

        android:name="com.useinsider.insider.APP_FRAMES_ENABLED"

        android:value="true" />

</application>

Without this flag, every frame rests in the DISABLED status: the SDK resolves no placements, nothing renders, and no error callback fires. This is read once at startup, so the app must be relaunched after adding it.

2. Create a placement

A placement is a named slot in your app where inline content can appear (e.g., home_page or product_detail_bottom). Create it in the InOne under Mobile App Settings > App Frames Placement. The placement ID you set on the view must match the one configured in the panel exactly.

3. Add the view

Add an InsiderAppFramesView where the content should appear. Give it a width and let the height resolve from the content by using wrap_content. The view measures itself to the height reported by the template.

<com.useinsider.insider.InsiderAppFramesView

    android:id="@+id/appFramesHomePage"

    android:layout_width="match_parent"

    android:layout_height="wrap_content"

    app:placementId="home_page" />

app:placementId is a custom attribute, so the layout's root element must declare the app namespace: xmlns:app="http://schemas.android.com/apk/res-auto". See the full example for a complete layout file.

You can also create and configure the view programmatically.

InsiderAppFramesView framesView = new InsiderAppFramesView(this);
framesView.setPlacementId("home_page");

container.addView(
    framesView,
    new LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT,
        LinearLayout.LayoutParams.WRAP_CONTENT
    )
);
val framesView = InsiderAppFramesView(this)
framesView.placementId = "home_page"

container.addView(
    framesView,
    LinearLayout.LayoutParams(
        LinearLayout.LayoutParams.MATCH_PARENT,
        LinearLayout.LayoutParams.WRAP_CONTENT
    )
)

Calling setPlacementId(null) (or passing an empty string) detaches the view from any placement and moves it to the NO_PLACEMENT status; changing the value to a different ID re-subscribes the view and refetches its content.

The view subscribes to its placement while attached to the window and unsubscribes when detached. You do not need to tear down anything manually when the screen goes away.

Hiding the view (GONE/INVISIBLE) does not stop it loading; content still fetches and renders. Visibility governs only what the user can be said to have seen. While the frame is hidden or its window is not visible, the SDK pauses the rendered content and withholds the impression until it is on screen again. This is deliberate so that a host that collapses an empty slot with GONE, exactly what this guide recommends, does not deadlock while waiting for a READY that a hidden view could never reach.

4. Wire the listener

Set an InsiderAppFramesViewListener to receive lifecycle and interaction callbacks. Every method on the interface is a default no-op, so implement only the ones you need. All callbacks are delivered on the main thread.

framesView.setAppFramesListener(listener);

The interface exposes five callbacks:

onStatusChanged

It indicates that the frame's content lifecycle status has changed. This is the single entry point for observing the frame's lifecycle, replacing separate "load started" / "load finished" callbacks. Refer to Track the frame status.

void onStatusChanged(
    InsiderAppFramesView view,
    InsiderAppFramesViewStatus status,
    InsiderAppFramesViewStatus previousStatus
)
override fun onStatusChanged(
    view: InsiderAppFramesView,
    status: InsiderAppFramesViewStatus,
    previousStatus: InsiderAppFramesViewStatus
)

onLoadFailed

It indicates that the frame failed to resolve, download, or render its content. The InsiderAppFramesError explains why. Refer to Handle errors. A placement with no campaign does not reach this callback. That is the UNAVAILABLE status, not an error.

void onLoadFailed(
    InsiderAppFramesView view,
    InsiderAppFramesError error
)
override fun onLoadFailed(
    view: InsiderAppFramesView,
    error: InsiderAppFramesError
)

onHeightChangeRequested

It indicates that the template reported the optimal height, in pixels, for its content. The view already applies this automatically, so implement it only if you drive the view's height yourself. Refer to Manage height.

void onHeightChangeRequested(
    InsiderAppFramesView view,
    int optimalHeight
)
override fun onHeightChangeRequested(
    view: InsiderAppFramesView,
    optimalHeight: Int
)

onDismissRequested

It indicates that the content is requested to be closed, for example, through a close button within the template. The SDK does not act on this; you can decide how to react. Refer to Handle dismissal.

void onDismissRequested(
    InsiderAppFramesView view
)
override fun onDismissRequested(
    view: InsiderAppFramesView
)

onActionTriggered

It indicates that the user interacted with an actionable element inside the content. The JSONObject carries the action's custom data from Action Builder and fires only when that data is non-empty. The SDK has already performed the action's own navigation. Refer to Handle actions.

void onActionTriggered(
    InsiderAppFramesView view,
    JSONObject actionData
)
override fun onActionTriggered(
    view: InsiderAppFramesView,
    actionData: JSONObject
)

For a typical integration, you only need onDismissRequested and onActionTriggered; the rest are informational.

Both onLoadFailed and onStatusChanged fire only on a status transition, and onLoadFailed always fires first. If the frame is already in an error state and a further failure lands on that same status, getError() is updated but neither callback fires again. Do not use onLoadFailed to count failures; use it to react to entering a failure state.

5. Track the frame status

Every frame exposes its current phase through getStatus(), an InsiderAppFramesViewStatus, and reports every change through onStatusChanged. During a successful load, a frame moves forward through RESOLVINGDOWNLOADINGRENDERINGREADY. READY is the only status in which content is on screen.

Status

Meaning

DETACHED

Idle; the view is not attached to a window. A view's initial status.

NO_PLACEMENT

Idle; placementId is null or empty.

RESOLVING

The SDK is fetching which campaign targets this placement.

DOWNLOADING

The SDK is downloading the resolved campaign.

RENDERING

The campaign is rendering but is not yet on screen; the frame stays collapsed.

READY

The content has rendered correctly. The only visible status.

UNAVAILABLE

No campaign targets this placement. Not an error; the frame renders nothing at a height of 0.

DISMISSED

The content closed itself, usually via a close button inside the template.

DISABLED

App Frames is not running. Not an error.

ERROR_RESOLVING

The placements fetch failed, so no campaign could be resolved.

ERROR_DOWNLOADING

A campaign was resolved, but its content failed to download, or its render URL was untrusted.

ERROR_RENDERING

The content downloaded, but the frame failed to render it.

Two helpers group the phases so you do not have to spell out each case:

  • status.isLoading(): True for RESOLVING, DOWNLOADING, and RENDERING.

  • status.isError(): True for the three ERROR_* states, exactly the statuses, in which getError() is non-null.

None of the resting states are latched (except DISABLED in its opt-in form): the frame moves forward again when fresh content arrives.

override fun onStatusChanged(
    view: InsiderAppFramesView,
    status: InsiderAppFramesViewStatus,
    previousStatus: InsiderAppFramesViewStatus
) {
    when {
        status == InsiderAppFramesViewStatus.READY -> showFrameContainer()
        status.isLoading() -> showLoadingPlaceholder()
        else -> collapseFrameContainer() // UNAVAILABLE, DISMISSED, DISABLED, ERROR
    }
}

DISABLED is not a failure. It means App Frames is not running at all, and it happens two ways.

Not opted in: The APP_FRAMES_ENABLED manifest flag is absent or false. This is terminal for the lifetime of the app.

Consent denied or SDK disabled: GDPR consent or mobile app access is false, or the SDK was disabled remotely. This is reversible, and the frame returns on its own once a session start confirms consent is granted again. In the meantime nothing is displayed, no request is made, and any content already on screen is cleared. Which of the two applies is visible in the SDK's debug logs.

A frame leaving the screen (the app backgrounding, an incoming call, or another screen covering this one) reports no status change for the round trip if it was already READY: the content it shows is still valid, so it is not redrawn.

6. Handle dismissal

When the user closes the frame from inside the template, the SDK moves the status to DISMISSED (so onStatusChanged fires first), then calls onDismissRequested, and does nothing else to your layout. The view stays in your hierarchy until you act. Hide or remove it yourself:

@Override
public void onDismissRequested(InsiderAppFramesView view) {
    view.setVisibility(View.GONE);
    // or: ((ViewGroup) view.getParent()).removeView(view);
}
override fun onDismissRequested(view: InsiderAppFramesView) {
    view.visibility = View.GONE
    // or: (view.parent as? ViewGroup)?.removeView(view)
}

If your templates include a close button and you do not implement onDismissRequested, tapping the button appears to do nothing. The dismissal itself is tracked automatically by the SDK either way.

7. Handle actions

The SDK already performs the action's own navigation. When a template triggers a deep link, the SDK opens it for you before it calls onActionTriggered. Do not open it again from the callback; you will navigate twice.

onActionTriggered exists for the extra data a campaign attaches to an action, not for the navigation itself. The JSONObject you receive is the action's custom object as configured in Action Builder, and the callback fires only when that object is present and non-empty. An action with no custom data never reaches your listener at all.

Use it for app-side side effects the campaign's own action cannot express, e.g., analytics tagging or refreshing a screen. Its keys are those your marketing team configures, so you can agree on a convention.

@Override
public void onActionTriggered(
    InsiderAppFramesView view,
    JSONObject actionData
) {
    // The SDK has already handled any deep link on this action.
    String campaignTag = actionData.optString("campaign_tag");
    if (!campaignTag.isEmpty()) {
        Log.i(TAG, "App Frame action: " + campaignTag);
    }
}
override fun onActionTriggered(
    view: InsiderAppFramesView,
    actionData: JSONObject
) {
    // The SDK has already handled any deep link on this action.
    val campaignTag = actionData.optString("campaign_tag")
    if (campaignTag.isNotEmpty()) {
        Log.i(TAG, "App Frame action: $campaignTag")
    }
}

Click analytics are recorded automatically; no element is tracked for attribution.

8. Manage height

The height contract has two modes. Pick one per view and stay consistent.

Self-sizing (default, recommended). Set android:layout_height="wrap_content". The view measures itself to the height the template reports, updates as the content changes, and collapses to zero when no content is available. Give it a width and leave the height to the view.

Fixed height (opt-in). If you set a fixed layout_height, the SDK respects it verbatim and never fights your layout. Content taller than the slot is not resized to fit. In this mode, onHeightChangeRequested reports the height the template would like to occupy, so you can drive your own sizing under manual control:

@Override
public void onHeightChangeRequested(
    InsiderAppFramesView view,
    int optimalHeight
) {
    ViewGroup.LayoutParams params = view.getLayoutParams();
    params.height = optimalHeight;
    view.setLayoutParams(params);
}
override fun onHeightChangeRequested(
    view: InsiderAppFramesView,
    optimalHeight: Int
) {
    view.updateLayoutParams {
        height = optimalHeight
    }
}

The updateLayoutParams extension in the Kotlin example comes from AndroidX Core KTX; without that dependency, update the view's layout params as the Java example does via setLayoutParams().

Collapse is not reported through this callback. When the frame goes empty, dismisses, or errors out, it zeroes its own height without calling onHeightChangeRequested. If you drive the height yourself, you must zero it when the status becomes NO_PLACEMENT, UNAVAILABLE, DISMISSED, DISABLED, or any ERROR_* state. Observe onStatusChanged for this.

Never reserve fixed empty space in case a frame arrives. The fail-silent contract assumes an empty placement occupies no space. A fixed-height placeholder shows the user an empty box whenever no campaign is active.

9. Handle errors

Every failure surfaced by App Frames is an InsiderAppFramesError, which extends Exception. Call getCode() to get an InsiderAppFramesErrorCode describing the failure, and getCause() for the originating exception where one exists (e.g., a network error). The same error is also readable at any time from view.getError(), which is non-null exactly while the status is one of the ERROR_* states.

Error code

Status

Meaning

UNKNOWN

An unclassified error; the fallback when no more specific code applies.

RESOLUTION_FAILED

ERROR_RESOLVING

The request to fetch the set of App Frames placements failed (e.g., due to  a network or server error).

RESPONSE_MALFORMED

ERROR_RESOLVING

The placements request succeeded, but its body could not be reconciled (e.g., the placements key is missing), so no placement was routed.

DOWNLOADING_FAILED

ERROR_DOWNLOADING

The placement's content failed to download.

PLACEMENT_UNTRUSTED

ERROR_DOWNLOADING

The placement's render URL failed trust validation, so its content was never fetched.

CONTENT_DISPLAY_FAILED

ERROR_RENDERING

The placement's content was downloaded but could not be displayed.

RENDERING_FAILED

ERROR_RENDERING

The rendered template reported an error dismissal; see getDismissCode().

@Override
public void onLoadFailed(
    InsiderAppFramesView view,
    InsiderAppFramesError error
) {
    switch (error.getCode()) {
        case RESOLUTION_FAILED:
            Log.w(TAG, "Placements fetch failed", error.getCause());
            break;
        case DOWNLOADING_FAILED:
        case PLACEMENT_UNTRUSTED:
            Log.w(
                TAG,
                "App Frame content unavailable: " + error.getMessage()
            );
            break;
        default:
            Log.w(
                TAG,
                "App Frames error: " + error.getMessage()
            );
            break;
    }
}
override fun onLoadFailed(
    view: InsiderAppFramesView,
    error: InsiderAppFramesError
) {
    when (error.code) {
        InsiderAppFramesErrorCode.RESOLUTION_FAILED ->
            Log.w(TAG, "Placements fetch failed", error.cause)

        InsiderAppFramesErrorCode.DOWNLOADING_FAILED,
        InsiderAppFramesErrorCode.PLACEMENT_UNTRUSTED ->
            Log.w(TAG, "App Frame content unavailable: ${error.message}")

        else ->
            Log.w(TAG, "App Frames error: ${error.message}")
    }
}

"No campaign right now" is not an error. When a placement has no active campaign for this user, the frame moves to the UNAVAILABLE status and onLoadFailed is never called. Do not look for an error code for this case; observe onStatusChanged instead. Likewise, treat every error above as "collapse the slot", not "show an error to the user".

The error also exposes getDismissCode() (defaulting to NO_DISMISS_CODE, -1), which carries the template's raw dismiss code for a RENDERING_FAILED failure; you do not need it for normal integration.

Limitations

  • App Frames must be opted into via the com.useinsider.insider.APP_FRAMES_ENABLED manifest flag. Without it, every frame rests in DISABLED, and nothing is ever requested.

  • A placement can have at most 10 active campaigns at once.

  • Campaign priority is High/Medium/Low; when two campaigns tie, the most recently created one wins.

  • Template images are limited to 500 KB per asset.

  • Placements must be defined on the SDK side (in an app release) before you can target them. You cannot create new placements without a developer adding the view.

  • App Frames requires the Insider SDK 17.0.0 or later on Android.

  • A fixed layout_height prevents the view from collapsing. Content clips instead of resizing, and the empty state no longer measures to zero. Use wrap_content unless you deliberately want a fixed height.

  • Reserving space for the frame in your layout displays an empty box when no campaign is active. Let the view collapse instead.

  • Without an onDismissRequested implementation, an in-template close button appears dead. The SDK never hides the view for you.

  • The view loads only while it is attached to the window. A view that is never added to the hierarchy never fetches content. Hiding an attached view with GONE does not stop the load; it only pauses the content and withholds the impression until the frame is on screen again.

  • UNAVAILABLE is the normal "no campaign right now" signal and produces no error callback at all.

  • Revoking GDPR consent or mobile app access moves every frame to DISABLED and clears content already on screen. Frames return on the next session start after consent is restored, not immediately.

Troubleshooting

Symptom

Likely cause

Fix

Nothing ever happens; getStatus() is DISABLED from the start.

The APP_FRAMES_ENABLED manifest flag is missing or false.

Add the <meta-data> flag under <application> and relaunch the app.

The status is DISABLED after it used to work.

GDPR consent or mobile app access was revoked, or the panel disabled the SDK.

Restore consent; frames return on the next session start, not immediately.

The frame never appears; status is UNAVAILABLE.

No active campaign targets the placement, or the placement is deactivated in the panel.

Expected behavior; the view collapses. Check Mobile App Settings > App Frames Placement and the campaign's status and segment.

The frame never appears; status stays NO_PLACEMENT or DETACHED.

placementId never set, placementId typo, or the view never attached to the window.

Verify the id matches the panel exactly; confirm the view is in the visible hierarchy.

The status is ERROR_RESOLVING, onLoadFailed reports RESOLUTION_FAILED.

Network or server error while fetching placements

Usually transient; the SDK retries automatically with backoff. Check connectivity and the cause from getCause().

The status is ERROR_DOWNLOADING with PLACEMENT_UNTRUSTED.

The campaign's render URL is not a valid absolute http(s) URL.

Report the campaign to Insider One. This is a server-side content issue, not an integration one.

The frame shows but with the wrong height or clipped content.

A fixed layout_height is set on the view.

Use wrap_content for self-sizing, or drive the height from onHeightChangeRequested.

The frame stays at its old height after the campaign goes away.

You drive the height manually and did not zero it on collapse.

Collapse is not reported through onHeightChangeRequested ; zero the height in onStatusChanged for UNAVAILABLE, DISMISSED, DISABLED, and ERROR_*.

The close button inside the frame does nothing.

onDismissRequested not implemented.

Implement the callback and hide or remove the view there.

Content seems stale after a campaign change.

Rendered content is cached in memory for the app process.

Content refreshes on app foreground and identity change; a fresh process always fetches anew.

Full example

A complete screen embedding two placements in a vertical LinearLayout inside a ScrollView.

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="12dp">

        <com.useinsider.insider.InsiderAppFramesView
            android:id="@+id/appFramesTopBanner"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:placementId="home_top_banner" />

        <!-- Your own content in between -->

        <com.useinsider.insider.InsiderAppFramesView
            android:id="@+id/appFramesBottomBanner"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:placementId="home_bottom_banner" />

    </LinearLayout>

</ScrollView>
package com.example.app;

import android.os.Bundle;
import android.util.Log;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;

import com.useinsider.insider.InsiderAppFramesError;
import com.useinsider.insider.InsiderAppFramesView;
import com.useinsider.insider.InsiderAppFramesViewListener;
import com.useinsider.insider.InsiderAppFramesViewStatus;

import org.json.JSONObject;

public class HomeActivity extends AppCompatActivity
        implements InsiderAppFramesViewListener {

    private static final String TAG = "AppFrames";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_home);

        bindFrame(R.id.appFramesTopBanner);
        bindFrame(R.id.appFramesBottomBanner);
    }

    private void bindFrame(int viewId) {
        InsiderAppFramesView frame = findViewById(viewId);
        frame.setAppFramesListener(this);
    }

    @Override
    public void onStatusChanged(
            @NonNull InsiderAppFramesView view,
            @NonNull InsiderAppFramesViewStatus status,
            @NonNull InsiderAppFramesViewStatus previousStatus
    ) {
        // READY is the only visible status; everything else collapses silently.
        Log.i(
                TAG,
                view.getPlacementId() + ": " + previousStatus + " -> " + status
        );
    }

    @Override
    public void onLoadFailed(
            @NonNull InsiderAppFramesView view,
            @NonNull InsiderAppFramesError error
    ) {
        // Fail silently — the view has already collapsed. Log for diagnostics only.
        Log.w(
                TAG,
                "Frame failed: " + view.getPlacementId() + " — " + error.getCode()
        );
    }

    @Override
    public void onDismissRequested(@NonNull InsiderAppFramesView view) {
        view.setVisibility(View.GONE);
    }

    @Override
    public void onActionTriggered(
            @NonNull InsiderAppFramesView view,
            @NonNull JSONObject actionData
    ) {
        // The SDK has already handled the action's own navigation; this is the campaign's custom data.
        Log.i(TAG, "Frame action: " + actionData);
    }
}
package com.example.app

import android.os.Bundle
import android.util.Log
import android.view.View

import androidx.appcompat.app.AppCompatActivity

import com.useinsider.insider.InsiderAppFramesError
import com.useinsider.insider.InsiderAppFramesView
import com.useinsider.insider.InsiderAppFramesViewListener
import com.useinsider.insider.InsiderAppFramesViewStatus

import org.json.JSONObject

class HomeActivity : AppCompatActivity(), InsiderAppFramesViewListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_home)

        bindFrame(R.id.appFramesTopBanner)
        bindFrame(R.id.appFramesBottomBanner)
    }

    private fun bindFrame(viewId: Int) {
        findViewById<InsiderAppFramesView>(viewId)
            .setAppFramesListener(this)
    }

    override fun onStatusChanged(
        view: InsiderAppFramesView,
        status: InsiderAppFramesViewStatus,
        previousStatus: InsiderAppFramesViewStatus
    ) {
        // READY is the only visible status; everything else collapses silently.
        Log.i(TAG, "${view.placementId}: $previousStatus -> $status")
    }

    override fun onLoadFailed(
        view: InsiderAppFramesView,
        error: InsiderAppFramesError
    ) {
        // Fail silently — the view has already collapsed. Log for diagnostics only.
        Log.w(TAG, "Frame failed: ${view.placementId} — ${error.code}")
    }

    override fun onDismissRequested(view: InsiderAppFramesView) {
        view.visibility = View.GONE
    }

    override fun onActionTriggered(
        view: InsiderAppFramesView,
        actionData: JSONObject
    ) {
        // The SDK has already handled the action's own navigation; this is the campaign's custom data.
        Log.i(TAG, "Frame action: $actionData")
    }

    companion object {
        private const val TAG = "AppFrames"
    }
}