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);
+ }
+}