diff --git a/CHANGELOG.md b/CHANGELOG.md index e1224e4aa..22358ca29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: + - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions. + - `Default` — title-only toolbar above the inbox list. + - `WithBackButton` — title plus a back navigation icon. The default back action calls `OnBackPressedDispatcher`; override it by having the host Activity or parent Fragment implement `IterableInboxToolbarBackListener`. + - `Custom(layoutRes)` — inflates the integrator's own toolbar layout. Views tagged with the reserved ids `@id/iterable_reserved_inbox_toolbar_action` and `@id/iterable_reserved_inbox_toolbar_title` are auto-wired to the SDK's back handler and title binding respectively. Both ids are optional. + - Configure programmatically via `IterableInboxFragment.newInstance(...)` (new 2-arg and 6-arg overloads) or via `IterableInboxActivity` intent extras (`TOOLBAR_OPTION` / `TOOLBAR_TITLE`). + - Requires the host activity to use a `Theme.AppCompat` descendant when the toolbar is enabled. - New `IterableInAppDisplayMode` enum to control how in-app messages interact with system bars. Configure via `IterableConfig.Builder.setInAppDisplayMode()`: - `FORCE_EDGE_TO_EDGE` (default) — draws in-app content behind system bars with transparent status and navigation bars. This preserves the previous SDK behavior. - `FOLLOW_APP_LAYOUT` — matches the host app's system bar configuration automatically. @@ -20,6 +27,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Migration guide **No action required for most apps.** The default `FORCE_EDGE_TO_EDGE` preserves the existing behavior where in-app content draws behind system bars. +The inflated root of `IterableInboxFragment` is now a `LinearLayout` instead of a `RelativeLayout`. Subclasses that override `onCreateView` and cast `super.onCreateView(...)`'s return value to `RelativeLayout` should change the cast to `ViewGroup` (or `LinearLayout`). + If the close button in your fullscreen in-app messages is obscured by the status bar, you can fix it by choosing one of these modes: ```java diff --git a/iterableapi-ui/build.gradle b/iterableapi-ui/build.gradle index 77e6bb3eb..c8bc5f42f 100644 --- a/iterableapi-ui/build.gradle +++ b/iterableapi-ui/build.gradle @@ -40,6 +40,8 @@ android { enableAndroidTestCoverage true } } + + testOptions.unitTests.includeAndroidResources = true } dependencies { @@ -54,6 +56,9 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test:core:1.6.1' + testImplementation 'androidx.test.ext:junit:1.2.1' + testImplementation 'org.robolectric:robolectric:4.14.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation 'androidx.test:runner:1.6.2' androidTestImplementation 'androidx.test:rules:1.6.1' diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt new file mode 100644 index 000000000..71df8d1af --- /dev/null +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOption.kt @@ -0,0 +1,44 @@ +package com.iterable.iterableapi.ui.inbox + +import androidx.annotation.LayoutRes +import java.io.Serializable + +/** + * Controls how [IterableInboxToolbarView] renders. Passed as a fragment argument or + * intent extra on [IterableInboxFragment] / [IterableInboxActivity]. + */ +sealed interface InboxToolbarOption : Serializable { + + /** No toolbar. The fragment renders identically to prior SDK versions. */ + data object None : InboxToolbarOption { + private fun readResolve(): Any = None + } + + /** A title-only toolbar above the inbox list. */ + data object Default : InboxToolbarOption { + private fun readResolve(): Any = Default + } + + /** A toolbar with the configured title plus a back navigation icon. */ + data object WithBackButton : InboxToolbarOption { + private fun readResolve(): Any = WithBackButton + } + + /** + * Inflates a fully custom toolbar layout supplied by the integrator. The integrator + * owns all wiring for their own views (menus, clicks, icons, etc.). + * + * Reserved opt-in ids - the SDK looks up these ids via `findViewById` and, if + * present, auto-wires them. The names are deliberately namespaced; do not reuse + * them on unrelated views in the custom layout. Omitting either id keeps the SDK + * from touching that view. + * + * - `@id/iterable_reserved_inbox_toolbar_action` - auto-wired to the SDK's default + * back handler. Override the action by implementing + * [IterableInboxToolbarBackListener] on the host Activity or parent Fragment. + * - `@id/iterable_reserved_inbox_toolbar_title` - if the view is a `TextView`, the + * SDK sets its text to the `toolbarTitle` argument (or the default "Inbox" + * string when null). + */ + data class Custom(@LayoutRes val layoutRes: Int) : InboxToolbarOption +} diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java index 990e7ef23..1a736f2cb 100644 --- a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxActivity.java @@ -4,6 +4,7 @@ import android.os.Bundle; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; import com.iterable.iterableapi.IterableConstants; import com.iterable.iterableapi.IterableLogger; @@ -11,6 +12,8 @@ import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.INBOX_MODE; import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.ITEM_LAYOUT_ID; +import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_OPTION; +import static com.iterable.iterableapi.ui.inbox.IterableInboxFragment.TOOLBAR_TITLE; /** * An activity wrapping {@link IterableInboxFragment} @@ -18,6 +21,11 @@ * Supports optional extras: * {@link IterableInboxFragment#INBOX_MODE} - {@link InboxMode} value with the inbox mode * {@link IterableInboxFragment#ITEM_LAYOUT_ID} - Layout resource id for inbox items + * {@link IterableInboxFragment#TOOLBAR_OPTION} - {@link InboxToolbarOption} variant for the opt-in inbox toolbar + * {@link IterableInboxFragment#TOOLBAR_TITLE} - Title shown in the opt-in inbox toolbar + * {@link IterableConstants#NO_MESSAGES_TITLE} - Title for the empty-inbox state + * {@link IterableConstants#NO_MESSAGES_BODY} - Body for the empty-inbox state + * {@link #ACTIVITY_TITLE} - Title set on the activity via {@code setTitle()} */ public class IterableInboxActivity extends AppCompatActivity { private static final String TAG = "IterableInboxActivity"; @@ -45,7 +53,14 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { noMessageTitle = extraBundle.getString(IterableConstants.NO_MESSAGES_TITLE, null); noMessageBody = extraBundle.getString(IterableConstants.NO_MESSAGES_BODY, null); } - inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody); + + InboxToolbarOption toolbarOption = IntentCompat.getSerializableExtra(intent, TOOLBAR_OPTION, InboxToolbarOption.class); + if (toolbarOption == null) { + toolbarOption = InboxToolbarOption.None.INSTANCE; + } + String toolbarTitle = intent.getStringExtra(TOOLBAR_TITLE); + + inboxFragment = IterableInboxFragment.newInstance(inboxMode, itemLayoutId, noMessageTitle, noMessageBody, toolbarOption, toolbarTitle); if (intent.getStringExtra(ACTIVITY_TITLE) != null) { setTitle(intent.getStringExtra(ACTIVITY_TITLE)); diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java index 4a970ebcd..ed49795ff 100644 --- a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragment.java @@ -1,5 +1,6 @@ package com.iterable.iterableapi.ui.inbox; +import android.content.Context; import android.content.Intent; import android.graphics.Insets; import android.os.Build; @@ -7,6 +8,7 @@ import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.BundleCompat; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; @@ -15,7 +17,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.RelativeLayout; import android.widget.TextView; import com.iterable.iterableapi.InboxSessionManager; @@ -35,19 +36,29 @@ * The main class for Inbox UI. Renders the list of Inbox messages and handles touch interaction: * tap on an item opens the in-app message, swipe left deletes it. *

- * To customize the UI, either create the fragment with {@link #newInstance(InboxMode, int)}, - * or subclass {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)}, + * To customize the UI, create the fragment with one of the {@code newInstance(...)} overloads + * (use {@link InboxToolbarOption} to opt into the built-in toolbar), or subclass + * {@link IterableInboxFragment} to use {@link #setAdapterExtension(IterableInboxAdapterExtension)}, * {@link #setComparator(IterableInboxComparator)} and {@link #setFilter(IterableInboxFilter)}. + * Implement {@link IterableInboxToolbarBackListener} on the host to handle toolbar back clicks. + *

+ * The host activity must use a {@code Theme.AppCompat} descendant when the opt-in + * toolbar is enabled. */ public class IterableInboxFragment extends Fragment implements IterableInAppManager.Listener, IterableInboxAdapter.OnListInteractionListener { private static final String TAG = "IterableInboxFragment"; public static final String INBOX_MODE = "inboxMode"; public static final String ITEM_LAYOUT_ID = "itemLayoutId"; + public static final String TOOLBAR_OPTION = "toolbarOption"; + public static final String TOOLBAR_TITLE = "toolbarTitle"; private InboxMode inboxMode = InboxMode.POPUP; private @LayoutRes int itemLayoutId = R.layout.iterable_inbox_item; private String noMessagesTitle; private String noMessagesBody; + private InboxToolbarOption toolbarOption = InboxToolbarOption.None.INSTANCE; + private @Nullable String toolbarTitle; + private @Nullable IterableInboxToolbarBackListener toolbarBackListener; TextView noMessagesTitleTextView; TextView noMessagesBodyTextView; RecyclerView recyclerView; @@ -93,6 +104,41 @@ public class IterableInboxFragment extends Fragment implements IterableInAppMana return inboxFragment; } + /** + * Create an Inbox fragment with toolbar customization; all other parameters use their defaults. + * + * @param toolbarOption Toolbar variant + * @param toolbarTitle Title shown in the toolbar, or null for the default "Inbox" string + * @return {@link IterableInboxFragment} instance + */ + @NonNull public static IterableInboxFragment newInstance( + @NonNull InboxToolbarOption toolbarOption, + @Nullable String toolbarTitle + ) { + return newInstance(InboxMode.POPUP, 0, null, null, toolbarOption, toolbarTitle); + } + + @NonNull public static IterableInboxFragment newInstance( + @NonNull InboxMode inboxMode, + @LayoutRes int itemLayoutId, + @Nullable String noMessagesTitle, + @Nullable String noMessagesBody, + @NonNull InboxToolbarOption toolbarOption, + @Nullable String toolbarTitle + ) { + IterableInboxFragment inboxFragment = new IterableInboxFragment(); + Bundle bundle = new Bundle(); + bundle.putSerializable(INBOX_MODE, inboxMode); + bundle.putInt(ITEM_LAYOUT_ID, itemLayoutId); + bundle.putString(IterableConstants.NO_MESSAGES_TITLE, noMessagesTitle); + bundle.putString(IterableConstants.NO_MESSAGES_BODY, noMessagesBody); + bundle.putSerializable(TOOLBAR_OPTION, toolbarOption); + bundle.putString(TOOLBAR_TITLE, toolbarTitle); + inboxFragment.setArguments(bundle); + + return inboxFragment; + } + /** * Set the inbox mode to display inbox messages either in a new activity or as an overlay * @@ -147,6 +193,23 @@ protected void setDateMapper(@NonNull IterableInboxDateMapper dateMapper) { } } + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + Fragment parent = getParentFragment(); + if (parent instanceof IterableInboxToolbarBackListener) { + toolbarBackListener = (IterableInboxToolbarBackListener) parent; + } else if (context instanceof IterableInboxToolbarBackListener) { + toolbarBackListener = (IterableInboxToolbarBackListener) context; + } + } + + @Override + public void onDetach() { + toolbarBackListener = null; + super.onDetach(); + } + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -171,20 +234,41 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (arguments.getString(IterableConstants.NO_MESSAGES_BODY) != null) { noMessagesBody = arguments.getString(IterableConstants.NO_MESSAGES_BODY); } + InboxToolbarOption toolbarOptionArg = BundleCompat.getSerializable(arguments, TOOLBAR_OPTION, InboxToolbarOption.class); + if (toolbarOptionArg != null) { + toolbarOption = toolbarOptionArg; + } + if (arguments.getString(TOOLBAR_TITLE) != null) { + toolbarTitle = arguments.getString(TOOLBAR_TITLE); + } + } + + ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.iterable_inbox_fragment, container, false); + + IterableInboxToolbarView toolbar = rootView.findViewById(R.id.iterable_inbox_toolbar); + toolbar.apply(toolbarOption, toolbarTitle); + // Prefer the host listener if one was discovered in onAttach; otherwise delegate + // to the fragment's host activity so we never depend on the view's Context chain + // to find a ComponentActivity. + if (toolbarBackListener != null) { + toolbar.setOnBackClickListener(v -> toolbarBackListener.onInboxToolbarBackClick()); + } else { + toolbar.setOnBackClickListener(v -> + requireActivity().getOnBackPressedDispatcher().onBackPressed() + ); } - RelativeLayout relativeLayout = (RelativeLayout) inflater.inflate(R.layout.iterable_inbox_fragment, container, false); - recyclerView = relativeLayout.findViewById(R.id.list); + recyclerView = rootView.findViewById(R.id.list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); IterableInboxAdapter adapter = new IterableInboxAdapter(IterableApi.getInstance().getInAppManager().getInboxMessages(), IterableInboxFragment.this, adapterExtension, comparator, filter, dateMapper); recyclerView.setAdapter(adapter); - noMessagesTitleTextView = relativeLayout.findViewById(R.id.emptyInboxTitle); - noMessagesBodyTextView = relativeLayout.findViewById(R.id.emptyInboxMessage); + noMessagesTitleTextView = rootView.findViewById(R.id.emptyInboxTitle); + noMessagesBodyTextView = rootView.findViewById(R.id.emptyInboxMessage); noMessagesTitleTextView.setText(noMessagesTitle); noMessagesBodyTextView.setText(noMessagesBody); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new IterableInboxTouchHelper(getContext(), adapter)); itemTouchHelper.attachToRecyclerView(recyclerView); - return relativeLayout; + return rootView; } @Override diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java new file mode 100644 index 000000000..0cac6282d --- /dev/null +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarBackListener.java @@ -0,0 +1,18 @@ +package com.iterable.iterableapi.ui.inbox; + +/** + * Implement on the host Activity or parent Fragment of {@link IterableInboxFragment} + * to handle back-navigation taps from the opt-in inbox toolbar. + * + *

Relevant when toolbar option is {@code InboxToolbarOption.WithBackButton} or a + * {@code InboxToolbarOption.Custom} layout includes a view with id + * {@code @id/iterable_reserved_inbox_toolbar_action}. If no host implements this + * interface, the fragment falls back to the host activity's + * {@code OnBackPressedDispatcher}. + * + *

The listener is discovered during {@code onAttach()}, so it survives process + * death - recreated fragments re-bind to the restored host automatically. + */ +public interface IterableInboxToolbarBackListener { + void onInboxToolbarBackClick(); +} diff --git a/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt new file mode 100644 index 000000000..e40ff38ed --- /dev/null +++ b/iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarView.kt @@ -0,0 +1,109 @@ +package com.iterable.iterableapi.ui.inbox + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.annotation.LayoutRes +import com.google.android.material.appbar.MaterialToolbar +import com.iterable.iterableapi.ui.R + +/** + * Opt-in toolbar for [IterableInboxFragment]. Configure via [apply] with an + * [InboxToolbarOption]. + * + * The view is empty until [apply] is called with a non-`None` option, so the + * `None` default does not inflate any Material widgets. + * + * **Theme requirement:** when a non-`None` option is applied, the host activity must + * use a `Theme.AppCompat` descendant - `MaterialToolbar` will throw an + * `InflateException` otherwise. If using the [IterableInboxActivity] this is a non-concern. + */ +class IterableInboxToolbarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var materialToolbar: MaterialToolbar? = null + private var backClickListener: OnClickListener? = null + private var isCustomLayout = false + + /** Configure the toolbar. Safe to call multiple times (e.g. on config change). */ + fun apply(option: InboxToolbarOption, title: String?) { + when (option) { + InboxToolbarOption.None -> { + visibility = View.GONE + // Intentionally do not inflate. `None` is the default and must not + // require an AppCompat-derived theme on the host. + } + InboxToolbarOption.Default -> { + showDefaultLayout() + visibility = View.VISIBLE + materialToolbar?.title = resolveTitle(title) + materialToolbar?.navigationIcon = null + materialToolbar?.setNavigationOnClickListener(null) + } + InboxToolbarOption.WithBackButton -> { + showDefaultLayout() + visibility = View.VISIBLE + materialToolbar?.title = resolveTitle(title) + materialToolbar?.setNavigationIcon(R.drawable.iterable_ic_arrow_back_24dp) + materialToolbar?.setNavigationOnClickListener { v -> dispatchBackClick(v) } + } + is InboxToolbarOption.Custom -> { + showCustomLayout(option.layoutRes) + visibility = View.VISIBLE + findViewById(R.id.iterable_reserved_inbox_toolbar_action)?.setOnClickListener { v -> + dispatchBackClick(v) + } + // `as? TextView` also matches subclasses like Button/EditText - documented behavior. + (findViewById(R.id.iterable_reserved_inbox_toolbar_title) as? TextView)?.text = resolveTitle(title) + } + } + } + + /** + * Override the default back-click behavior. Honored for + * `InboxToolbarOption.WithBackButton` and for `InboxToolbarOption.Custom` layouts that + * include a view with id `@id/iterable_reserved_inbox_toolbar_action`. + * + * Hosting via [IterableInboxFragment] wires this for you. For standalone usage, + * pass a listener if the view's `Context` isn't a [ComponentActivity]. + */ + fun setOnBackClickListener(listener: OnClickListener?) { + backClickListener = listener + } + + private fun dispatchBackClick(v: View) { + val override = backClickListener + if (override != null) { + override.onClick(v) + return + } + (context as? ComponentActivity)?.onBackPressedDispatcher?.onBackPressed() + } + + /** Inflate the SDK toolbar layout if it isn't already showing. */ + private fun showDefaultLayout() { + if (materialToolbar != null && !isCustomLayout) return + removeAllViews() + LayoutInflater.from(context).inflate(R.layout.iterable_inbox_toolbar, this, true) + materialToolbar = findViewById(R.id.iterableInboxMaterialToolbar) + isCustomLayout = false + } + + /** Swap in the integrator's custom toolbar layout. Always re-inflates. */ + private fun showCustomLayout(@LayoutRes layoutRes: Int) { + removeAllViews() + LayoutInflater.from(context).inflate(layoutRes, this, true) + materialToolbar = null + isCustomLayout = true + } + + private fun resolveTitle(title: String?): String = + title ?: context.getString(R.string.iterable_inbox_default_title) +} diff --git a/iterableapi-ui/src/main/res/drawable/iterable_ic_arrow_back_24dp.xml b/iterableapi-ui/src/main/res/drawable/iterable_ic_arrow_back_24dp.xml new file mode 100644 index 000000000..d5dfb2713 --- /dev/null +++ b/iterableapi-ui/src/main/res/drawable/iterable_ic_arrow_back_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/iterableapi-ui/src/main/res/layout/iterable_inbox_fragment.xml b/iterableapi-ui/src/main/res/layout/iterable_inbox_fragment.xml index f94252cb7..d2a5d3c48 100644 --- a/iterableapi-ui/src/main/res/layout/iterable_inbox_fragment.xml +++ b/iterableapi-ui/src/main/res/layout/iterable_inbox_fragment.xml @@ -1,34 +1,53 @@ - - - - + android:visibility="gone" + tools:visibility="visible" /> - - + android:layout_height="0dp" + android:layout_weight="1"> + + + + + + + + + + + diff --git a/iterableapi-ui/src/main/res/layout/iterable_inbox_toolbar.xml b/iterableapi-ui/src/main/res/layout/iterable_inbox_toolbar.xml new file mode 100644 index 000000000..13e94f4c9 --- /dev/null +++ b/iterableapi-ui/src/main/res/layout/iterable_inbox_toolbar.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/iterableapi-ui/src/main/res/values/ids.xml b/iterableapi-ui/src/main/res/values/ids.xml new file mode 100644 index 000000000..710c09afd --- /dev/null +++ b/iterableapi-ui/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/iterableapi-ui/src/main/res/values/strings.xml b/iterableapi-ui/src/main/res/values/strings.xml new file mode 100644 index 000000000..1c0bda443 --- /dev/null +++ b/iterableapi-ui/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Inbox + diff --git a/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOptionTest.kt b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOptionTest.kt new file mode 100644 index 000000000..961e7f1c1 --- /dev/null +++ b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/InboxToolbarOptionTest.kt @@ -0,0 +1,86 @@ +package com.iterable.iterableapi.ui.inbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +class InboxToolbarOptionTest { + + @Test + fun customCarriesLayoutRes() { + val option = InboxToolbarOption.Custom(layoutRes = 42) + assertEquals(42, option.layoutRes) + } + + @Test + fun customDataClassEqualityIsByValue() { + assertEquals(InboxToolbarOption.Custom(7), InboxToolbarOption.Custom(7)) + assertNotEquals(InboxToolbarOption.Custom(7), InboxToolbarOption.Custom(8)) + } + + @Test + fun dataObjectsAreReferenceSingletonsInProcess() { + // Belt-and-suspenders: in-process the data object should be the same instance. + assertSame(InboxToolbarOption.None, InboxToolbarOption.None) + assertSame(InboxToolbarOption.Default, InboxToolbarOption.Default) + assertSame(InboxToolbarOption.WithBackButton, InboxToolbarOption.WithBackButton) + } + + @Test + fun dataObjectsRoundTripPreserveSingletonIdentity() { + // The `readResolve()` overrides on each data object must return the singleton + // instance, not the freshly-deserialized copy — otherwise Java consumers + // comparing with `==` after a Bundle/Intent round-trip get a surprise null + // result. This test fails (assertSame downgrades to a different reference) + // if the readResolve()s are ever removed. + assertSame(InboxToolbarOption.None, roundTrip(InboxToolbarOption.None)) + assertSame(InboxToolbarOption.Default, roundTrip(InboxToolbarOption.Default)) + assertSame(InboxToolbarOption.WithBackButton, roundTrip(InboxToolbarOption.WithBackButton)) + } + + @Test + fun customRoundTripsThroughJavaSerialization() { + val original = InboxToolbarOption.Custom(layoutRes = 1234) + val restored = roundTrip(original) + assertEquals(original, restored) + assertEquals(1234, (restored as InboxToolbarOption.Custom).layoutRes) + } + + @Test + fun whenBranchExhaustivelyDispatchesEachVariant() { + val variants: List = listOf( + InboxToolbarOption.None, + InboxToolbarOption.Default, + InboxToolbarOption.WithBackButton, + InboxToolbarOption.Custom(layoutRes = 99) + ) + val labels = variants.map { option -> + when (option) { + InboxToolbarOption.None -> "none" + InboxToolbarOption.Default -> "default" + InboxToolbarOption.WithBackButton -> "back" + is InboxToolbarOption.Custom -> "custom-${option.layoutRes}" + } + } + assertEquals(listOf("none", "default", "back", "custom-99"), labels) + } + + private fun roundTrip(option: InboxToolbarOption): InboxToolbarOption { + val bytes = ByteArrayOutputStream().use { byteStream -> + ObjectOutputStream(byteStream).use { it.writeObject(option) } + byteStream.toByteArray() + } + return ObjectInputStream(ByteArrayInputStream(bytes)).use { input -> + val restored = input.readObject() + assertTrue("Round-tripped value must remain an InboxToolbarOption", + restored is InboxToolbarOption) + restored as InboxToolbarOption + } + } +} diff --git a/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragmentTest.kt b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragmentTest.kt new file mode 100644 index 000000000..58686a81c --- /dev/null +++ b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxFragmentTest.kt @@ -0,0 +1,120 @@ +package com.iterable.iterableapi.ui.inbox + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import com.iterable.iterableapi.IterableApi +import com.iterable.iterableapi.ui.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Defends the unchanged-path contract for `IterableInboxFragment`. The default + * `InboxToolbarOption.None` (used by every pre-toolbar caller) must render exactly as + * the fragment did before the toolbar feature was added: list fills the parent, no + * toolbar inflated, no AppCompat-or-above theme requirement beyond what already existed. + * + * These tests exist specifically because PR review caught two regressions on this path: + * - The list collapsed to 0dp height under a `RelativeLayout`+`GONE` constraint chain. + * - The toolbar layout was inflated even for `None`, requiring AppCompat unconditionally. + * + * Both are regression-test material; do not delete without proof the contract changed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class IterableInboxFragmentTest { + + /** Plain AppCompat host; no Material attrs available. */ + class PlainAppCompatActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(androidx.appcompat.R.style.Theme_AppCompat_Light_NoActionBar) + super.onCreate(savedInstanceState) + } + } + + @Before + fun setUp() { + // The fragment's onCreateView reads inbox messages from IterableApi. + // Initialize with a stub key so it doesn't NPE during view setup. + IterableApi.initialize(RuntimeEnvironment.getApplication(), "test-key") + } + + private fun mountDefaultFragment(): Pair { + val activity = Robolectric.buildActivity(PlainAppCompatActivity::class.java).setup().get() + val fragment = IterableInboxFragment.newInstance() + activity.supportFragmentManager.beginTransaction() + .add(android.R.id.content, fragment) + .commitNow() + return activity to fragment + } + + /** + * Default-path layout contract: under `InboxToolbarOption.None` (no args), + * the list fills the parent and the toolbar takes zero space. + * + * Catches: PR-review blocker #1 (RelativeLayout+GONE collapsed the list to 0dp). + */ + @Test + fun newInstance_default_listFillsParentAndToolbarTakesNoSpace() { + val (_, fragment) = mountDefaultFragment() + val root = fragment.requireView() + + val toolbar = root.findViewById(R.id.iterable_inbox_toolbar) + assertEquals("Toolbar must be GONE under InboxToolbarOption.None", + View.GONE, toolbar.visibility) + assertEquals("Toolbar must inflate no children under None " + + "(otherwise we pay an AppCompat-or-above cost the host didn't opt into)", + 0, (toolbar as ViewGroup).childCount) + + // Robolectric does not lay out fragments inserted via commitNow until something + // triggers a layout pass. Drive one explicitly with realistic dimensions so we + // can assert measured size of the RecyclerView. + val widthSpec = View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY) + val heightSpec = View.MeasureSpec.makeMeasureSpec(1920, View.MeasureSpec.EXACTLY) + root.measure(widthSpec, heightSpec) + root.layout(0, 0, root.measuredWidth, root.measuredHeight) + + val list = root.findViewById(R.id.list) + assertNotNull(list) + assertTrue("RecyclerView must take vertical space under None " + + "(would have caught the layout_below=GONE-view collapse regression). " + + "Got measuredHeight=${list.measuredHeight}", + list.measuredHeight > 0) + } + + /** + * AppCompat-host inflation: under `InboxToolbarOption.None`, no Material widgets + * may be inflated, so even hosts on plain `Theme.AppCompat.*` (no Material attrs) + * must not throw. + * + * Catches: PR-review blocker #2 (always-inflated MaterialToolbar required AppCompat + * even when the toolbar feature was disabled). + */ + @Test + fun newInstance_default_inflatesUnderPlainAppCompatTheme() { + // If the toolbar layout inflates unconditionally, MaterialToolbar fails to + // resolve Material attributes under this AppCompat-only theme and throws + // InflateException at view creation. Beyond not throwing, also assert the + // structural invariants - otherwise a future regression that swallows the + // exception or inflates a degraded view would still pass this test. + val (_, fragment) = mountDefaultFragment() + val root = fragment.requireView() + + val toolbar = root.findViewById(R.id.iterable_inbox_toolbar) as ViewGroup + assertEquals("Toolbar must be GONE under None on a plain AppCompat host", + View.GONE, toolbar.visibility) + assertEquals("Toolbar must inflate no Material children under None - this is " + + "the only thing that lets non-Material hosts use the fragment safely", + 0, toolbar.childCount) + } +} diff --git a/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarViewTest.kt b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarViewTest.kt new file mode 100644 index 000000000..cfa4fd2c7 --- /dev/null +++ b/iterableapi-ui/src/test/java/com/iterable/iterableapi/ui/inbox/IterableInboxToolbarViewTest.kt @@ -0,0 +1,142 @@ +package com.iterable.iterableapi.ui.inbox + +import android.view.ContextThemeWrapper +import android.view.View +import androidx.activity.ComponentActivity +import com.google.android.material.appbar.MaterialToolbar +import com.iterable.iterableapi.ui.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowView + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class IterableInboxToolbarViewTest { + + private fun newToolbar(): IterableInboxToolbarView { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + // MaterialToolbar requires an AppCompat-derived theme. + val themed = ContextThemeWrapper( + activity, + androidx.appcompat.R.style.Theme_AppCompat_Light_NoActionBar + ) + return IterableInboxToolbarView(themed) + } + + private fun materialToolbar(view: IterableInboxToolbarView): MaterialToolbar = + view.findViewById(R.id.iterableInboxMaterialToolbar) + + @Test + fun applyNone_hidesTheView() { + val view = newToolbar() + view.apply(InboxToolbarOption.None, title = "ignored") + assertEquals(View.GONE, view.visibility) + } + + @Test + fun applyDefault_showsTitleWithoutNavigationIcon() { + val view = newToolbar() + view.apply(InboxToolbarOption.Default, title = "My Inbox") + val toolbar = materialToolbar(view) + assertEquals(View.VISIBLE, view.visibility) + assertEquals("My Inbox", toolbar.title) + assertNull("Default should not set a navigation icon", toolbar.navigationIcon) + } + + @Test + fun applyDefault_withNullTitle_fallsBackToDefaultString() { + val view = newToolbar() + view.apply(InboxToolbarOption.Default, title = null) + val expected = view.context.getString(R.string.iterable_inbox_default_title) + assertEquals(expected, materialToolbar(view).title) + } + + @Test + fun applyWithBackButton_setsNavigationIconAndClickListener() { + val view = newToolbar() + view.apply(InboxToolbarOption.WithBackButton, title = "Inbox") + val toolbar = materialToolbar(view) + assertEquals(View.VISIBLE, view.visibility) + assertEquals("Inbox", toolbar.title) + assertNotNull("WithBackButton must set a navigation icon", toolbar.navigationIcon) + } + + @Test + fun setOnBackClickListener_isInvokedWhenNavigationIconClicked() { + val view = newToolbar() + view.apply(InboxToolbarOption.WithBackButton, title = null) + + var overrideFired = false + view.setOnBackClickListener { overrideFired = true } + + // MaterialToolbar exposes the click via the listener registered with + // setNavigationOnClickListener. Robolectric's ShadowView.innerText is brittle + // for the nav icon child; instead we read back the listener and invoke it. + materialToolbar(view).navigationOnClickListener().onClick(view) + + assertTrue("Override back-click listener was not invoked", overrideFired) + } + + @Test + fun setOnBackClickListener_clearedWithNull_fallsBackToDefault() { + val view = newToolbar() + view.apply(InboxToolbarOption.WithBackButton, title = null) + + var overrideFired = false + view.setOnBackClickListener { overrideFired = true } + view.setOnBackClickListener(null) + + materialToolbar(view).navigationOnClickListener().onClick(view) + + assertFalse("Override should have been cleared", overrideFired) + } + + @Test + fun customToDefaultTransition_restoresSdkToolbar() { + val view = newToolbar() + + // Start in Default - the SDK's MaterialToolbar should be present. + view.apply(InboxToolbarOption.Default, title = "Inbox") + assertNotNull(view.findViewById(R.id.iterableInboxMaterialToolbar)) + + // Switch to Custom using a layout we know exists in the SDK. After this, + // the integrator-supplied layout replaces the SDK's toolbar tree. + view.apply(InboxToolbarOption.Custom(R.layout.iterable_inbox_item), title = null) + assertEquals(View.VISIBLE, view.visibility) + + // Switch back to Default - the SDK's MaterialToolbar must be re-inflated + // and the new title must be applied. + view.apply(InboxToolbarOption.Default, title = "Back") + val restored: MaterialToolbar? = view.findViewById(R.id.iterableInboxMaterialToolbar) + assertNotNull("Default after Custom must restore the SDK toolbar", restored) + assertEquals("Back", restored!!.title) + } +} + +/** + * Reads back the navigation OnClickListener that was registered via + * [androidx.appcompat.widget.Toolbar.setNavigationOnClickListener]. Toolbar stores + * the listener on its internal navigation button child; the cleanest way under + * Robolectric is to attach our own click to `null`-safe traverse via a shadow. + */ +private fun MaterialToolbar.navigationOnClickListener(): View.OnClickListener { + // Toolbar always creates an internal ImageButton for the nav icon when one is + // set; locate it and return its registered click listener via Robolectric shadow. + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child is android.widget.ImageButton) { + val shadow = org.robolectric.Shadows.shadowOf(child) as ShadowView + return shadow.onClickListener + ?: error("Navigation icon has no click listener attached") + } + } + error("Toolbar has no navigation icon child") +}