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; + } + + /** + * 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) { @@ -306,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 b77ce437..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 @@ -71,8 +71,12 @@ 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 mPendingDeepLinkPath = null; + private ArrayAdapter mFilterAdapter; private String mFilterText = ""; private String mSearchText = ""; @@ -137,6 +141,8 @@ protected void onCreate(Bundle savedInstanceState) { FiveCallsApplication.analyticsManager().trackPageview("/", this); } + maybeHandleDeepLink(intent); + setContentView(binding.getRoot()); setSupportActionBar(binding.toolbar); @@ -284,6 +290,13 @@ protected void onDestroy() { super.onDestroy(); } + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + maybeHandleDeepLink(intent); + } + @Override protected void onResume() { super.onResume(); @@ -457,6 +470,10 @@ public void onIssuesReceived(List issues) { mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); binding.swipeContainer.setRefreshing(false); + // Only handle deep link if we have both issues and contacts loaded + if (mIssuesAdapter.hasContacts()) { + maybeHandlePendingDeepLink(); + } } }; @@ -523,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(); } }; @@ -754,4 +774,63 @@ public void onDismissed(Snackbar transientBottomBar, int event) { } }); } + + private void maybeHandleDeepLink(Intent intent) { + if (intent == null || intent.getData() == null) { + return; + } + + Uri data = intent.getData(); + 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)) { + // 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 (mPendingDeepLinkPath == null) { + return; + } + + // 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.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(); + showSnackbar(R.string.issue_not_found, Snackbar.LENGTH_LONG); + } + } } 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 diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index bda57427..c95f1df1 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -368,6 +368,9 @@ Enter an address or zip code + + Could not find this issue. It may no longer be relevant + Share 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); + } +}