From 533e0c959360d7982ea3f9449b86152067e2be6a Mon Sep 17 00:00:00 2001 From: Scott Peterson Date: Sat, 1 Nov 2025 15:29:07 -0400 Subject: [PATCH 1/5] Added deep link support 1. AndroidManifest.xml Added a deep link intent filter to MainActivity to handle https://5calls.org/issue/{issueId} URLs: - Added with android:autoVerify="true" for Android App Links - Configured to handle HTTPS scheme, 5calls.org host, and /issue/ path prefix 2. MainActivity.java Added deep link handling logic: - New field: mPendingDeepLinkIssueId to store the issue ID from the deep link - handleDeepLink(): Parses incoming intent URIs and extracts the issue ID from URLs like https://5calls.org/issue/1051 - onNewIntent(): Handles deep links when the app is already running (singleTop launch mode) - handlePendingDeepLink(): Searches for the issue in the loaded issues list and either opens it or shows an error - Modified onCreate() to call handleDeepLink() on initial launch - Modified onIssuesReceived() callback to call handlePendingDeepLink() after issues are loaded 3. IssuesAdapter.java - Added getAllIssues() getter method to allow MainActivity to search through all loaded issues 4. strings.xml Added two new error messages: - issue_not_found: "This issue isn't relevant anymore" - issue_inactive: "This issue is no longer active" --- 5calls/app/src/main/AndroidManifest.xml | 12 ++++ .../a5calls/adapter/IssuesAdapter.java | 4 ++ .../a5calls/controller/MainActivity.java | 55 +++++++++++++++++++ 5calls/app/src/main/res/values/strings.xml | 6 ++ 4 files changed, 77 insertions(+) diff --git a/5calls/app/src/main/AndroidManifest.xml b/5calls/app/src/main/AndroidManifest.xml index a7b3a024..520f2807 100644 --- a/5calls/app/src/main/AndroidManifest.xml +++ b/5calls/app/src/main/AndroidManifest.xml @@ -26,6 +26,18 @@ + + + + + + + + + getAllIssues() { + return mAllIssues; + } + @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java index b77ce437..c6677df0 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java @@ -73,6 +73,8 @@ public class MainActivity extends AppCompatActivity implements IssuesAdapter.Cal private static final String KEY_SHOW_LOW_ACCURACY_WARNING = "showLowAccuracyWarning"; private final AccountManager accountManager = AccountManager.Instance; + private String mPendingDeepLinkIssueId = null; + private ArrayAdapter mFilterAdapter; private String mFilterText = ""; private String mSearchText = ""; @@ -137,6 +139,8 @@ protected void onCreate(Bundle savedInstanceState) { FiveCallsApplication.analyticsManager().trackPageview("/", this); } + handleDeepLink(intent); + setContentView(binding.getRoot()); setSupportActionBar(binding.toolbar); @@ -284,6 +288,13 @@ protected void onDestroy() { super.onDestroy(); } + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleDeepLink(intent); + } + @Override protected void onResume() { super.onResume(); @@ -457,6 +468,7 @@ public void onIssuesReceived(List issues) { mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); binding.swipeContainer.setRefreshing(false); + handlePendingDeepLink(); } }; @@ -754,4 +766,47 @@ public void onDismissed(Snackbar transientBottomBar, int event) { } }); } + + private void handleDeepLink(Intent intent) { + if (intent == null || intent.getData() == null) { + return; + } + + Uri data = intent.getData(); + if (data.getHost() != null && data.getHost().equals("5calls.org")) { + List pathSegments = data.getPathSegments(); + if (pathSegments.size() >= 2 && pathSegments.get(0).equals("issue")) { + mPendingDeepLinkIssueId = pathSegments.get(1); + } + } + } + + private void handlePendingDeepLink() { + if (mPendingDeepLinkIssueId == null) { + return; + } + + String issueId = mPendingDeepLinkIssueId; + mPendingDeepLinkIssueId = null; + + Issue targetIssue = null; + for (Issue issue : mIssuesAdapter.getAllIssues()) { + if (issue.id.equals(issueId)) { + targetIssue = issue; + break; + } + } + + if (targetIssue != null) { + if (!targetIssue.active) { + hideSnackbars(); + showSnackbar(R.string.issue_inactive, Snackbar.LENGTH_LONG); + } else { + startIssueActivity(this, targetIssue); + } + } else { + hideSnackbars(); + showSnackbar(R.string.issue_not_found, Snackbar.LENGTH_LONG); + } + } } diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index bda57427..995e965c 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -368,6 +368,12 @@ Enter an address or zip code + + This issue isn\'t relevant anymore + + + This issue is no longer active + Share From 4789677bec9d5d212f8bca8ce90a6539081395a9 Mon Sep 17 00:00:00 2001 From: Scott Peterson Date: Sun, 2 Nov 2025 18:45:53 -0500 Subject: [PATCH 2/5] Switched deep link handling to handle the permalink slug instead of the issueId also responded to dektar's comments --- .../a5calls/controller/MainActivity.java | 35 +++++++++---------- 5calls/app/src/main/res/values/strings.xml | 5 +-- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java index c6677df0..f3682aaf 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java @@ -71,9 +71,11 @@ public class MainActivity extends AppCompatActivity implements IssuesAdapter.Cal private static final String KEY_FILTER_ITEM_SELECTED = "filterItemSelected"; private static final String KEY_SEARCH_TEXT = "searchText"; private static final String KEY_SHOW_LOW_ACCURACY_WARNING = "showLowAccuracyWarning"; + private static final String DEEP_LINK_HOST = "5calls.org"; + private static final String DEEP_LINK_PATH_ISSUE = "issue"; private final AccountManager accountManager = AccountManager.Instance; - private String mPendingDeepLinkIssueId = null; + private String mPendingDeepLinkSlug = null; private ArrayAdapter mFilterAdapter; private String mFilterText = ""; @@ -139,7 +141,7 @@ protected void onCreate(Bundle savedInstanceState) { FiveCallsApplication.analyticsManager().trackPageview("/", this); } - handleDeepLink(intent); + maybeHandleDeepLink(intent); setContentView(binding.getRoot()); @@ -292,7 +294,7 @@ protected void onDestroy() { protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); - handleDeepLink(intent); + maybeHandleDeepLink(intent); } @Override @@ -468,7 +470,7 @@ public void onIssuesReceived(List issues) { mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); binding.swipeContainer.setRefreshing(false); - handlePendingDeepLink(); + maybeHandlePendingDeepLink(); } }; @@ -767,43 +769,38 @@ public void onDismissed(Snackbar transientBottomBar, int event) { }); } - private void handleDeepLink(Intent intent) { + private void maybeHandleDeepLink(Intent intent) { if (intent == null || intent.getData() == null) { return; } Uri data = intent.getData(); - if (data.getHost() != null && data.getHost().equals("5calls.org")) { + if (data.getHost() != null && data.getHost().equals(DEEP_LINK_HOST)) { List pathSegments = data.getPathSegments(); - if (pathSegments.size() >= 2 && pathSegments.get(0).equals("issue")) { - mPendingDeepLinkIssueId = pathSegments.get(1); + if (pathSegments.size() >= 2 && pathSegments.get(0).equals(DEEP_LINK_PATH_ISSUE)) { + mPendingDeepLinkSlug = pathSegments.get(1); } } } - private void handlePendingDeepLink() { - if (mPendingDeepLinkIssueId == null) { + private void maybeHandlePendingDeepLink() { + if (mPendingDeepLinkSlug == null) { return; } - String issueId = mPendingDeepLinkIssueId; - mPendingDeepLinkIssueId = null; + String slug = mPendingDeepLinkSlug; + mPendingDeepLinkSlug = null; Issue targetIssue = null; for (Issue issue : mIssuesAdapter.getAllIssues()) { - if (issue.id.equals(issueId)) { + if (issue.permalink != null && issue.permalink.contains("/" + slug)) { targetIssue = issue; break; } } if (targetIssue != null) { - if (!targetIssue.active) { - hideSnackbars(); - showSnackbar(R.string.issue_inactive, Snackbar.LENGTH_LONG); - } else { - startIssueActivity(this, targetIssue); - } + startIssueActivity(this, targetIssue); } else { hideSnackbars(); showSnackbar(R.string.issue_not_found, Snackbar.LENGTH_LONG); diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index 995e965c..c95f1df1 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -369,10 +369,7 @@ Enter an address or zip code - This issue isn\'t relevant anymore - - - This issue is no longer active + Could not find this issue. It may no longer be relevant Share From 618b1c65b2cf0335c079bc6f82f31db47f655484 Mon Sep 17 00:00:00 2001 From: Scott Peterson Date: Sun, 9 Nov 2025 12:43:33 -0500 Subject: [PATCH 3/5] Fixed calls section when deep linking Issues Fixed: When users opened the app via a deep link (e.g., from a notification), the IssueActivity would show "no calls for this issue" because contacts weren't loaded yet. Race condition: Issues and contacts load in parallel, but we were launching IssueActivity as soon as issues loaded, without waiting for contacts Path matching issue: Substring matching could cause false matches between similar slugs (national vs state-specific issues) Path format mismatch: Deep link paths didn't match API permalink format (missing slashes) Contacts not populated: Issue objects retrieved for deep linking bypassed the RecyclerView, so their contacts field was never populated --- .../a5calls/adapter/IssuesAdapter.java | 38 ++++++++++------ .../a5calls/controller/MainActivity.java | 43 +++++++++++++++---- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java index 5357cd14..96bc1b7f 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java @@ -243,6 +243,30 @@ public List getAllIssues() { return mAllIssues; } + /** + * Populates an issue's contacts list based on its contact areas. + * This is normally done in onBindViewHolder, but is needed for deep linking + * where we bypass the RecyclerView. + */ + public void populateIssueContacts(Issue issue) { + if (issue == null || issue.contactAreas.isEmpty()) { + return; + } + + issue.contacts = new ArrayList(); + for (String contactArea : issue.contactAreas) { + for (Contact contact : mContacts) { + if (TextUtils.equals(contact.area, contactArea) && + !issue.contacts.contains(contact)) { + if (TextUtils.equals(contact.area, Contact.AREA_HOUSE) && mIsSplitDistrict) { + issue.isSplit = true; + } + issue.contacts.add(contact); + } + } + } + } + @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -310,19 +334,7 @@ public void onClick(View v) { return; } - issue.contacts = new ArrayList(); - for (String contactArea : issue.contactAreas) { - for (Contact contact : mContacts) { - if (TextUtils.equals(contact.area, contactArea) && - !issue.contacts.contains(contact)) { - if (TextUtils.equals(contact.area, Contact.AREA_HOUSE) && mIsSplitDistrict) { - issue.isSplit = true; - } - - issue.contacts.add(contact); - } - } - } + populateIssueContacts(issue); displayPreviousCallStats(issue, vh); } else if (type == VIEW_TYPE_EMPTY_REQUEST) { EmptyRequestViewHolder vh = (EmptyRequestViewHolder) holder; diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java index f3682aaf..f965e084 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java @@ -75,7 +75,7 @@ public class MainActivity extends AppCompatActivity implements IssuesAdapter.Cal private static final String DEEP_LINK_PATH_ISSUE = "issue"; private final AccountManager accountManager = AccountManager.Instance; - private String mPendingDeepLinkSlug = null; + private String mPendingDeepLinkPath = null; private ArrayAdapter mFilterAdapter; private String mFilterText = ""; @@ -470,7 +470,10 @@ public void onIssuesReceived(List issues) { mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); binding.swipeContainer.setRefreshing(false); - maybeHandlePendingDeepLink(); + // Only handle deep link if we have both issues and contacts loaded + if (mIssuesAdapter.hasContacts()) { + maybeHandlePendingDeepLink(); + } } }; @@ -537,12 +540,15 @@ public void onDismissed(Snackbar transientBottomBar, int event) { mShowLowAccuracyWarning = false; } } - + // If the state changed, refresh issues to get state-specific issues if (stateChanged) { FiveCallsApi api = AppSingleton.getInstance(getApplicationContext()).getJsonController(); api.getIssues(); } + + // Handle pending deep link now that contacts are loaded + maybeHandlePendingDeepLink(); } }; @@ -778,28 +784,49 @@ private void maybeHandleDeepLink(Intent intent) { if (data.getHost() != null && data.getHost().equals(DEEP_LINK_HOST)) { List pathSegments = data.getPathSegments(); if (pathSegments.size() >= 2 && pathSegments.get(0).equals(DEEP_LINK_PATH_ISSUE)) { - mPendingDeepLinkSlug = pathSegments.get(1); + // Store the full path (e.g., "issue/slug" or "issue/state/slug") + // to avoid false matches with substring matching + StringBuilder pathBuilder = new StringBuilder(); + for (int i = 0; i < pathSegments.size(); i++) { + if (i > 0) { + pathBuilder.append("/"); + } + pathBuilder.append(pathSegments.get(i)); + } + mPendingDeepLinkPath = pathBuilder.toString(); } } } private void maybeHandlePendingDeepLink() { - if (mPendingDeepLinkSlug == null) { + if (mPendingDeepLinkPath == null) { return; } - String slug = mPendingDeepLinkSlug; - mPendingDeepLinkSlug = null; + // Wait for both issues and contacts to be loaded before handling deep link + if (mIssuesAdapter.getAllIssues().isEmpty() || !mIssuesAdapter.hasContacts()) { + return; + } + + String path = mPendingDeepLinkPath; + mPendingDeepLinkPath = null; + + // Normalize the path for matching: add leading/trailing slashes to match API format + // API permalinks are like "/issue/slug/" but our path is "issue/slug" + String normalizedPath = "/" + path + "/"; Issue targetIssue = null; for (Issue issue : mIssuesAdapter.getAllIssues()) { - if (issue.permalink != null && issue.permalink.contains("/" + slug)) { + if (issue.permalink != null && issue.permalink.equals(normalizedPath)) { targetIssue = issue; break; } } if (targetIssue != null) { + // Populate the issue's contacts before launching IssueActivity + // This is normally done in IssuesAdapter.onBindViewHolder, but we're bypassing that + mIssuesAdapter.populateIssueContacts(targetIssue); startIssueActivity(this, targetIssue); } else { hideSnackbars(); From 68915c54eb7da3ed85009e831d0eb86ca03cacd5 Mon Sep 17 00:00:00 2001 From: Scott Peterson Date: Sun, 9 Nov 2025 18:57:53 -0500 Subject: [PATCH 4/5] Added spanish string for issue_not_found --- 5calls/app/src/main/res/values-es/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/5calls/app/src/main/res/values-es/strings.xml b/5calls/app/src/main/res/values-es/strings.xml index 3a3d7720..8fcbbb01 100644 --- a/5calls/app/src/main/res/values-es/strings.xml +++ b/5calls/app/src/main/res/values-es/strings.xml @@ -373,6 +373,9 @@ Actualización + + No se pudo encontrar este problema. Puede que ya no sea relevante. + Enviar correo electrónico From e312d860be20762cf1ab92cb6f71fde9560619f3 Mon Sep 17 00:00:00 2001 From: Scott Peterson Date: Sun, 9 Nov 2025 19:25:42 -0500 Subject: [PATCH 5/5] Added unit tests around issuesAdapter and deep linking --- .../a5calls/adapter/IssuesAdapterTest.java | 169 ++++++++++++++- .../controller/DeepLinkParsingTest.java | 203 ++++++++++++++++++ 2 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 5calls/app/src/test/java/org/a5calls/android/a5calls/controller/DeepLinkParsingTest.java diff --git a/5calls/app/src/test/java/org/a5calls/android/a5calls/adapter/IssuesAdapterTest.java b/5calls/app/src/test/java/org/a5calls/android/a5calls/adapter/IssuesAdapterTest.java index 2a911019..fece45d4 100644 --- a/5calls/app/src/test/java/org/a5calls/android/a5calls/adapter/IssuesAdapterTest.java +++ b/5calls/app/src/test/java/org/a5calls/android/a5calls/adapter/IssuesAdapterTest.java @@ -5,6 +5,7 @@ import com.google.gson.reflect.TypeToken; import org.a5calls.android.a5calls.FakeJSONData; +import org.a5calls.android.a5calls.model.Contact; import org.a5calls.android.a5calls.model.Issue; import org.junit.Test; import org.junit.runner.RunWith; @@ -16,6 +17,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; /** * Unit tests for IssuesAdapter. @@ -266,7 +269,7 @@ public void testSortIssuesWithMetaPriority_allWithoutMeta() { assertEquals(4, result.size()); // Should be sorted by sort field: 100(sort=300), 200(sort=100), 300(sort=400), 400(sort=200) // Expected order by sort: 200, 400, 100, 300 - assertEquals("200", result.get(0).id); + assertEquals("200", result.getFirst().id); assertEquals("400", result.get(1).id); assertEquals("100", result.get(2).id); assertEquals("300", result.get(3).id); @@ -287,8 +290,8 @@ public void testSortIssuesWithMetaPriority_allWithMeta() { assertEquals(4, result.size()); // All should have meta, sorted by sort field: 200(100), 400(200), 100(300), 300(400) - assertEquals("200", result.get(0).id); - assertEquals("CA", result.get(0).meta); + assertEquals("200", result.getFirst().id); + assertEquals("CA", result.getFirst().meta); assertEquals("400", result.get(1).id); assertEquals("NY", result.get(1).meta); assertEquals("100", result.get(2).id); @@ -309,7 +312,7 @@ public void testSortIssuesWithMetaPriority_mixedMetaValues() { assertEquals(4, result.size()); // First two should have meta values (sorted by sort field: 200(100), 400(200)) - assertEquals("200", result.get(0).id); + assertEquals("200", result.getFirst().id); assertEquals("CA", result.get(0).meta); assertEquals(100, result.get(0).sort); assertEquals("400", result.get(1).id); @@ -341,13 +344,165 @@ public void testSortIssuesWithMetaPriority_nullMetaHandling() { assertEquals(4, result.size()); // Issues with non-empty meta come first (sorted by sort: 200(100), 400(200)) - assertEquals("200", result.get(0).id); - assertEquals("CA", result.get(0).meta); + assertEquals("200", result.getFirst().id); + assertEquals("CA", result.getFirst().meta); assertEquals("400", result.get(1).id); assertEquals("NY", result.get(1).meta); - + // Issues with null/empty meta come last (sorted by sort: 100(300), 300(400)) assertEquals("100", result.get(2).id); assertEquals("300", result.get(3).id); } + + // Test data for populateIssueContacts tests + private static final String CONTACTS_TEST_ISSUE_DATA = """ + [ + { + "id": "1000", + "name": "Test Issue with House and Senate", + "reason": "Test reason", + "script": "Test script", + "categories": [{"name": "Test"}], + "contactType": "REPS", + "contactAreas": ["US House", "US Senate"], + "outcomeModels": [], + "stats": { "calls": 0 }, + "active": true, + "meta": "" + }, + { + "id": "2000", + "name": "Test Issue with only Senate", + "reason": "Test reason", + "script": "Test script", + "categories": [{"name": "Test"}], + "contactType": "REPS", + "contactAreas": ["US Senate"], + "outcomeModels": [], + "stats": { "calls": 0 }, + "active": true, + "meta": "" + }, + { + "id": "3000", + "name": "Test Issue with no contact areas", + "reason": "Test reason", + "script": "Test script", + "categories": [{"name": "Test"}], + "contactType": "REPS", + "contactAreas": [], + "outcomeModels": [], + "stats": { "calls": 0 }, + "active": true, + "meta": "" + } + ]"""; + + @Test + public void testPopulateIssueContacts_basicAssignment() { + Gson gson = new GsonBuilder().serializeNulls().create(); + Type listType = new TypeToken>(){}.getType(); + List issues = gson.fromJson(CONTACTS_TEST_ISSUE_DATA, listType); + Issue issue = issues.getFirst(); + + List contacts = new ArrayList<>(); + Contact senatorA = Contact.createPlaceholder("sen1", "Senator A", "", "US Senate"); + senatorA.isPlaceholder = false; + contacts.add(senatorA); + + Contact senatorB = Contact.createPlaceholder("sen2", "Senator B", "", "US Senate"); + senatorB.isPlaceholder = false; + contacts.add(senatorB); + + Contact rep = Contact.createPlaceholder("rep1", "Representative", "", "US House"); + rep.isPlaceholder = false; + contacts.add(rep); + + IssuesAdapter adapter = new IssuesAdapter(null, null); + adapter.setContacts(contacts, false, IssuesAdapter.NO_ERROR); + adapter.populateIssueContacts(issue); + + assertEquals(3, issue.contacts.size()); + assertEquals("Representative", issue.contacts.get(0).name); + assertEquals("Senator A", issue.contacts.get(1).name); + assertEquals("Senator B", issue.contacts.get(2).name); + } + + @Test + public void testPopulateIssueContacts_splitDistrict() { + Gson gson = new GsonBuilder().serializeNulls().create(); + Type listType = new TypeToken>(){}.getType(); + List issues = gson.fromJson(CONTACTS_TEST_ISSUE_DATA, listType); + Issue issue = issues.getFirst(); + + List contacts = new ArrayList<>(); + Contact rep = Contact.createPlaceholder("rep1", "Representative", "", "US House"); + rep.isPlaceholder = false; + contacts.add(rep); + + IssuesAdapter adapter = new IssuesAdapter(null, null); + adapter.setContacts(contacts, true, IssuesAdapter.NO_ERROR); + adapter.populateIssueContacts(issue); + + assertTrue(issue.isSplit); + } + + @Test + public void testPopulateIssueContacts_emptyContactAreas() { + Gson gson = new GsonBuilder().serializeNulls().create(); + Type listType = new TypeToken>(){}.getType(); + List issues = gson.fromJson(CONTACTS_TEST_ISSUE_DATA, listType); + Issue issue = issues.get(2); + + List contacts = new ArrayList<>(); + Contact senator = Contact.createPlaceholder("sen1", "Senator", "", "US Senate"); + senator.isPlaceholder = false; + contacts.add(senator); + + IssuesAdapter adapter = new IssuesAdapter(null, null); + adapter.setContacts(contacts, false, IssuesAdapter.NO_ERROR); + adapter.populateIssueContacts(issue); + + assertNull(issue.contacts); + } + + @Test + public void testPopulateIssueContacts_noMatchingContacts() { + Gson gson = new GsonBuilder().serializeNulls().create(); + Type listType = new TypeToken>(){}.getType(); + List issues = gson.fromJson(CONTACTS_TEST_ISSUE_DATA, listType); + Issue issue = issues.getFirst(); + + List contacts = new ArrayList<>(); + Contact stateRep = Contact.createPlaceholder("state1", "State Representative", "", "State Lower"); + stateRep.isPlaceholder = false; + contacts.add(stateRep); + + IssuesAdapter adapter = new IssuesAdapter(null, null); + adapter.setContacts(contacts, false, IssuesAdapter.NO_ERROR); + adapter.populateIssueContacts(issue); + + assertEquals(0, issue.contacts.size()); + } + + @Test + public void testPopulateIssueContacts_noDuplicates() { + Gson gson = new GsonBuilder().serializeNulls().create(); + Type listType = new TypeToken>(){}.getType(); + List issues = gson.fromJson(CONTACTS_TEST_ISSUE_DATA, listType); + Issue issue = issues.get(1); + + List contacts = new ArrayList<>(); + Contact senator = Contact.createPlaceholder("sen1", "Senator A", "", "US Senate"); + senator.isPlaceholder = false; + contacts.add(senator); + contacts.add(senator); + + IssuesAdapter adapter = new IssuesAdapter(null, null); + adapter.setContacts(contacts, false, IssuesAdapter.NO_ERROR); + adapter.populateIssueContacts(issue); + + assertEquals(1, issue.contacts.size()); + assertEquals("Senator A", issue.contacts.getFirst().name); + } } \ No newline at end of file diff --git a/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/DeepLinkParsingTest.java b/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/DeepLinkParsingTest.java new file mode 100644 index 00000000..5c64b90d --- /dev/null +++ b/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/DeepLinkParsingTest.java @@ -0,0 +1,203 @@ +package org.a5calls.android.a5calls.controller; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.net.Uri; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.List; + +/** + * Unit tests for deep link parsing logic. + * Tests the URL parsing and path extraction without requiring a full Activity. + */ +@RunWith(RobolectricTestRunner.class) +public class DeepLinkParsingTest { + + /** + * Helper method to extract path from URI, mimicking MainActivity.maybeHandleDeepLink logic + */ + private String extractPathFromUri(Uri uri) { + if (uri == null || uri.getHost() == null) { + return null; + } + + if (!uri.getHost().equals("5calls.org")) { + return null; + } + + List pathSegments = uri.getPathSegments(); + if (pathSegments.size() < 2 || !pathSegments.getFirst().equals("issue")) { + return null; + } + + // Build the full path from segments + StringBuilder pathBuilder = new StringBuilder(); + for (int i = 0; i < pathSegments.size(); i++) { + if (i > 0) { + pathBuilder.append("/"); + } + pathBuilder.append(pathSegments.get(i)); + } + return pathBuilder.toString(); + } + + /** + * Helper to normalize path for matching (add leading/trailing slashes) + */ + private String normalizePath(String path) { + if (path == null) { + return null; + } + return "/" + path + "/"; + } + + @Test + public void testExtractPath_simpleIssue() { + Uri uri = Uri.parse("https://5calls.org/issue/my-slug"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/my-slug", path); + } + + @Test + public void testExtractPath_withTrailingSlash() { + Uri uri = Uri.parse("https://5calls.org/issue/my-slug/"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/my-slug", path); + } + + @Test + public void testExtractPath_stateSpecificIssue() { + Uri uri = Uri.parse("https://5calls.org/issue/state/utah/my-slug"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/state/utah/my-slug", path); + } + + @Test + public void testExtractPath_invalidHost() { + Uri uri = Uri.parse("https://wronghost.com/issue/my-slug"); + String path = extractPathFromUri(uri); + + assertNull(path); + } + + @Test + public void testExtractPath_nonIssuePath() { + Uri uri = Uri.parse("https://5calls.org/about"); + String path = extractPathFromUri(uri); + + assertNull(path); + } + + @Test + public void testExtractPath_onlyIssue() { + // Path with just "issue" but no slug + Uri uri = Uri.parse("https://5calls.org/issue"); + String path = extractPathFromUri(uri); + + assertNull(path); + } + + @Test + public void testExtractPath_emptyPath() { + Uri uri = Uri.parse("https://5calls.org/"); + String path = extractPathFromUri(uri); + + assertNull(path); + } + + @Test + public void testNormalizePath_simple() { + String normalized = normalizePath("issue/my-slug"); + + assertEquals("/issue/my-slug/", normalized); + } + + @Test + public void testNormalizePath_stateSpecific() { + String normalized = normalizePath("issue/state/utah/my-slug"); + + assertEquals("/issue/state/utah/my-slug/", normalized); + } + + @Test + public void testPathMatching_exactMatch() { + String extractedPath = "issue/federal-budget-government-shutdown"; + String normalizedPath = normalizePath(extractedPath); + String apiPermalink = "/issue/federal-budget-government-shutdown/"; + + assertEquals(apiPermalink, normalizedPath); + } + + @Test + public void testPathMatching_noFalsePositives() { + // Ensure "issue/my-slug" doesn't match "issue/state/utah/my-slug" + String extractedPath1 = "issue/my-slug"; + String normalizedPath1 = normalizePath(extractedPath1); + String apiPermalink2 = "/issue/state/utah/my-slug/"; + + assertEquals("/issue/my-slug/", normalizedPath1); + assertNotEquals(apiPermalink2, normalizedPath1); + } + + @Test + public void testPathMatching_casePreserved() { + // URLs should be case-sensitive + String extractedPath = "issue/My-Slug"; + String normalizedPath = normalizePath(extractedPath); + + assertEquals("/issue/My-Slug/", normalizedPath); + } + + @Test + public void testExtractPath_withQueryParameters() { + // Query parameters should be ignored + Uri uri = Uri.parse("https://5calls.org/issue/my-slug?utm_source=test"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/my-slug", path); + } + + @Test + public void testExtractPath_withFragment() { + // Fragments should be ignored + Uri uri = Uri.parse("https://5calls.org/issue/my-slug#section"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/my-slug", path); + } + + @Test + public void testExtractPath_multipleSlashes() { + // Test URL with multiple consecutive slashes + Uri uri = Uri.parse("https://5calls.org//issue//my-slug"); + String path = extractPathFromUri(uri); + + // Uri.parse handles this, but we should still get valid segments + assertNotNull(path); + } + + @Test + public void testExtractPath_specialCharacters() { + // Test slug with hyphens and numbers + Uri uri = Uri.parse("https://5calls.org/issue/h-r-1234-bill-name"); + String path = extractPathFromUri(uri); + + assertNotNull(path); + assertEquals("issue/h-r-1234-bill-name", path); + } +}