diff --git a/app/build.gradle b/app/build.gradle index b9763ec29..48fa3eaaa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ android { multiDexEnabled true // Version code should be increased after each release - versionCode 70 + versionCode 72 versionName new Date().format('yyyy.MM.dd') testApplicationId "net.osmtracker.test" @@ -61,6 +61,8 @@ android { } testOptions { unitTests.returnDefaultValues = true + // This flag is required for Robolectric to find XML resources + unitTests.includeAndroidResources = true unitTests.all { it.jvmArgs = [ '--add-opens', 'java.base/java.io=ALL-UNNAMED', @@ -85,6 +87,12 @@ dependencies { exclude group: 'net.sf.kxml', module: 'kxml2' exclude group: 'xmlpull', module: 'xmlpull' } + // For upload notes to osm server + implementation ('de.westnordost:osmapi-notes:3.1'){ + // Already included in Android + exclude group: 'net.sf.kxml', module: 'kxml2' + exclude group: 'xmlpull', module: 'xmlpull' + } // App intro implementation 'com.github.AppIntro:AppIntro:6.3.1' @@ -95,22 +103,24 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.preference:preference:1.2.0' + implementation 'androidx.preference:preference-ktx:1.2.1' // Required -- JUnit 4 framework testImplementation 'junit:junit:4.13.2' - // Robolectric environment + // Robolectric + testImplementation 'org.robolectric:robolectric:4.11.1' + // AndroidX Test core for Robolectric testImplementation "androidx.test:core:1.6.1" // Mockito framework testImplementation "org.mockito:mockito-core:3.12.4" - testImplementation 'org.powermock:powermock-core:2.0.9' - testImplementation 'org.powermock:powermock-api-mockito2:2.0.9' - testImplementation 'org.powermock:powermock-module-junit4:2.0.9' // Required for local unit tests. Prevent null in JSONObject, JSONArray, etc. testImplementation 'org.json:json:20240303' // Required for instrumented tests androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.7.0' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test:rules:1.6.1' } diff --git a/app/src/androidTest/java/net/osmtracker/activity/PreferencesTest.java b/app/src/androidTest/java/net/osmtracker/activity/PreferencesTest.java new file mode 100644 index 000000000..bef2a78b8 --- /dev/null +++ b/app/src/androidTest/java/net/osmtracker/activity/PreferencesTest.java @@ -0,0 +1,218 @@ +package net.osmtracker.activity; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.clearText; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.stringContainsInOrder; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.matcher.ViewMatchers; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.Arrays; + + +@RunWith(AndroidJUnit4.class) +public class PreferencesTest { + + private Context context; + private ActivityScenario activity; + + @Before + public void setup() { + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + // Reset preferences to default before each test to ensure a clean state + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().clear().commit(); + + // Launch the activity + activity = ActivityScenario.launch(Preferences.class); + } + + @After + public void tearDown() { + activity.close(); + } + + /** + * Test that the Storage Directory preference logic works to rejects empty input. + */ + @Test + public void testStorageDirectoryValidatesNonEmpty() { + String keyTitle = context.getString(R.string.prefs_storage_dir); + String defaultValue = OSMTracker.Preferences.VAL_STORAGE_DIR; + + // Looks for storage directory preference + scrollToAndClick(keyTitle); + + // Try to save an empty value + onView(withId(android.R.id.edit)).perform(clearText()); + onView(withText(android.R.string.ok)).perform(click()); + + // Open the preference to verify the value in the list remains the default (unchanged) + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .check(matches(hasDescendant(withText(defaultValue)))); + } + + /** + * Test that the Storage Directory preference logic works to automatically append a leading + * slash separator if missing. + */ + @Test + public void testStorageDirectoryValidatesAppendLeadingSlash() { + String keyTitle = context.getString(R.string.prefs_storage_dir); + String expected = File.separator + "my_folder"; + + + // Looks for storage directory preference + scrollToAndClick(keyTitle); + + // Try to type a value without a slash + onView(withId(android.R.id.edit)).perform(clearText()); + onView(withId(android.R.id.edit)) + .perform(typeText("my_folder")); + onView(withText(android.R.string.ok)).perform(click()); + + // Open the preference to verify the value in the list is the expected + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .check(matches(hasDescendant(withText(expected)))); + } + + /** + * Test Numeric Input logic (GPS Logging Interval): update summary with suffix. + */ + @Test + public void testNumericInputLogic() { + String title = context.getString(R.string.prefs_gps_logging_interval); + String suffix = context.getString(R.string.prefs_gps_logging_interval_seconds); + + scrollToAndClick(title); + + // Enter a valid number + onView(withId(android.R.id.edit)) + .perform(clearText(), typeText("30")); + onView(withText(android.R.string.ok)).perform(click()); + + // Verify summary format: "30 seconds. " + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .check(matches(hasDescendant(withText(stringContainsInOrder(Arrays.asList("30", + suffix)))))); + } + + /** + * Test that the Reset button in numeric preferences restores the default value. + */ + @Test + public void testResetButtonResetsValue() { + String title = context.getString(R.string.prefs_gps_logging_interval); + String suffix = context.getString(R.string.prefs_gps_logging_interval_seconds); + String defaultValue = OSMTracker.Preferences.VAL_GPS_LOGGING_INTERVAL; + + scrollToAndClick(title); + + // Set a custom value "50" + onView(withId(android.R.id.edit)).perform(clearText(), typeText("50")); + onView(withText(android.R.string.ok)).perform(click()); + + // Verify custom value is set + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .check(matches(hasDescendant(withText(stringContainsInOrder(Arrays.asList("50", + suffix)))))); + + // Reopen dialog + scrollToAndClick(title); + + // Click the Reset button (Neutral button) + onView(withText(R.string.prefs_reset_default_value)).perform(click()); + + // Verify value is back to default "0" + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .check(matches(hasDescendant(withText(stringContainsInOrder(Arrays.asList( + defaultValue, + suffix)))))); + } + + /** + * Test ListPreference custom summary logic (Screen Orientation) + * Should show "Selected Value. \n ..." (don't check for the 2nd line of the summary) + */ + @Test + public void testListPreferenceCustomSummary() { + String title = context.getString(R.string.prefs_ui_orientation); + + scrollToAndClick(title); + + // Select 1st option from array resource entries + String[] entries = context.getResources() + .getStringArray(R.array.prefs_ui_orientation_options_keys); + onView(withText(entries[0])).perform(click()); + + // Verify the two-line summary exists + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)).check(matches(hasDescendant( + withText(stringContainsInOrder(Arrays.asList(entries[0], ".\n")))))); + } + + /** + * Test Clear OAuth Data logic. + */ + @Test + public void testClearOAuthData() { + String title = context.getString(R.string.prefs_osm_clear_oauth_data); + + // Inject a fake token to enable the button + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context) + .edit(); + editor.putString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, "fake_token"); + editor.commit(); + + // Relaunch to refresh UI state + ActivityScenario.launch(Preferences.class); + + + scrollToAndClick(title); + + // Click OK on Confirmation Dialog + onView(withText(R.string.prefs_osm_clear_oauth_data_dialog)).check(matches(isDisplayed())); + onView(withText(android.R.string.ok)).perform(click()); + + // Verify token is gone in prefs + assert(!PreferenceManager.getDefaultSharedPreferences(context) + .contains(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN)); + } + + // --- Helper Methods --- + + /** + * Helper to scroll to a preference in the RecyclerView and click it. + */ + private void scrollToAndClick(String text) { + onView(ViewMatchers.isAssignableFrom(RecyclerView.class)) + .perform(RecyclerViewActions.actionOnItem( + hasDescendant(withText(text)), + click())); + } + +} diff --git a/app/src/androidTest/java/net/osmtracker/layouts/DeleteLayoutTest.java b/app/src/androidTest/java/net/osmtracker/layouts/DeleteLayoutTest.java index 63fd0a464..c1bc2cdbb 100644 --- a/app/src/androidTest/java/net/osmtracker/layouts/DeleteLayoutTest.java +++ b/app/src/androidTest/java/net/osmtracker/layouts/DeleteLayoutTest.java @@ -1,21 +1,5 @@ package net.osmtracker.layouts; -import android.Manifest; -import androidx.test.rule.ActivityTestRule; -import androidx.test.rule.GrantPermissionRule; - -import net.osmtracker.R; -import net.osmtracker.activity.ButtonsPresets; -import net.osmtracker.activity.Preferences; -import net.osmtracker.util.CustomLayoutsUtils; -import net.osmtracker.util.TestUtils; - -import org.junit.Rule; -import org.junit.Test; - -import java.io.IOException; -import java.util.ArrayList; - import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -26,72 +10,94 @@ import static net.osmtracker.util.TestUtils.getStringResource; import static net.osmtracker.util.TestUtils.injectMockLayout; import static net.osmtracker.util.TestUtils.listFiles; +import static org.apache.commons.io.FileUtils.deleteDirectory; import static org.hamcrest.Matchers.equalToIgnoringCase; import static org.junit.Assert.assertFalse; -import static org.apache.commons.io.FileUtils.deleteDirectory; +import android.Manifest; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.rule.GrantPermissionRule; + +import net.osmtracker.R; +import net.osmtracker.activity.ButtonsPresets; +import net.osmtracker.db.DataHelper; +import net.osmtracker.util.CustomLayoutsUtils; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; public class DeleteLayoutTest { - @Rule - public GrantPermissionRule storagePermission = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE); - - @Rule - public ActivityTestRule mRule = new ActivityTestRule(ButtonsPresets.class) { - @Override - protected void beforeActivityLaunched() { - //Makes sure that only the mock layout exists - try { - deleteDirectory(getLayoutsDirectory()); - injectMockLayout(layoutName, ISOLanguageCode); - - } catch (IOException e) { - e.printStackTrace(); - } - } - }; - - private static String layoutName = "mock"; - private static String ISOLanguageCode = "es"; - - /** - * Assumes being in the ButtonsPresets activity - * Deletes the layout with the received name - */ - private void deleteLayout(String layoutName){ - onView(withText(layoutName)).perform(longClick()); - onView(withText(getStringResource(R.string.buttons_presets_context_menu_delete))).perform(click()); - String textToMatch = getStringResource(R.string.buttons_presets_delete_positive_confirmation); - onView(withText(equalToIgnoringCase(textToMatch))).perform(click()); - } - - /** - * Deletes the mock layout and then checks that: - * - The UI option doesn't appear anymore - * - The XML file is deleted - * - A Toast is shown to inform about what happened - * - The icons directory is deleted - */ - @Test - public void layoutDeletionTest(){ - - deleteLayout(layoutName); - - // Check the informative Toast is shown - checkToastIsShownWith(getStringResource(R.string.buttons_presets_successful_delete)); - - // Check the layout doesn't appear anymore - onView(withText(layoutName)).check(doesNotExist()); - - // List files after the deletion - ArrayList filesAfterDeletion = listFiles(getLayoutsDirectory()); - - // Check the xml file was deleted - String layoutFileName = CustomLayoutsUtils.createFileName(layoutName, ISOLanguageCode); - assertFalse(filesAfterDeletion.contains(layoutFileName)); - - // Check the icons folder was deleted - assertFalse(filesAfterDeletion.contains(layoutName+ Preferences.ICONS_DIR_SUFFIX)); - - } + @Rule + public GrantPermissionRule storagePermission = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE); + + public ActivityScenario activity; + + private static final String layoutName = "mock"; + private static final String ISOLanguageCode = "es"; + + @Before + public void setUp() { + // Makes sure that only the mock layout exists + try { + deleteDirectory(getLayoutsDirectory()); + injectMockLayout(layoutName, ISOLanguageCode); + } catch (IOException e) { + e.printStackTrace(); + } + // Launch activity + activity = ActivityScenario.launch(ButtonsPresets.class); + activity.moveToState(Lifecycle.State.RESUMED); + } + + @After + public void tearDown() { + activity.close(); + } + + /** + * Assumes being in the ButtonsPresets activity + * Deletes the layout with the received name + */ + private void deleteLayout() { + onView(withText(layoutName)).perform(longClick()); + onView(withText(getStringResource(R.string.buttons_presets_context_menu_delete))).perform(click()); + String textToMatch = getStringResource(R.string.buttons_presets_delete_positive_confirmation); + onView(withText(equalToIgnoringCase(textToMatch))).perform(click()); + } + + /** + * Deletes the mock layout and then checks that: + * - The UI option doesn't appear anymore + * - The XML file is deleted + * - A Toast is shown to inform about what happened + * - The icons directory is deleted + */ + @Test + public void layoutDeletionTest() { + deleteLayout(); + + // Check the informative Toast is shown + checkToastIsShownWith(getStringResource(R.string.buttons_presets_successful_delete)); + + // Check the layout doesn't appear anymore + onView(withText(layoutName)).check(doesNotExist()); + + // List files after the deletion + ArrayList filesAfterDeletion = listFiles(getLayoutsDirectory()); + + // Check the xml file was deleted + String layoutFileName = CustomLayoutsUtils.createFileName(layoutName, ISOLanguageCode); + assertFalse(filesAfterDeletion.contains(layoutFileName)); + + // Check the icons folder was deleted + assertFalse(filesAfterDeletion.contains(layoutName + DataHelper.LAYOUTS_ICONS_DIR_SUFFIX)); + } } diff --git a/app/src/androidTest/java/net/osmtracker/layouts/DownloadLayoutTest.java b/app/src/androidTest/java/net/osmtracker/layouts/DownloadLayoutTest.java index 1c20d9c7e..5d7c99d24 100644 --- a/app/src/androidTest/java/net/osmtracker/layouts/DownloadLayoutTest.java +++ b/app/src/androidTest/java/net/osmtracker/layouts/DownloadLayoutTest.java @@ -1,12 +1,9 @@ package net.osmtracker.layouts; -import static androidx.test.espresso.Espresso.onData; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; import static androidx.test.espresso.action.ViewActions.click; -import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.PreferenceMatchers.withTitleText; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withId; @@ -17,10 +14,13 @@ import android.Manifest; import android.content.SharedPreferences; -import android.preference.PreferenceManager; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.Espresso; -import androidx.test.rule.ActivityTestRule; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.espresso.matcher.ViewMatchers; import androidx.test.rule.GrantPermissionRule; import net.osmtracker.OSMTracker; @@ -29,6 +29,8 @@ import net.osmtracker.util.TestUtils; import org.apache.commons.io.FileUtils; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -36,107 +38,119 @@ public class DownloadLayoutTest { - private final int WAIT_VIEW_TIMEOUT = 5000; - - @Rule - public GrantPermissionRule fineLocationPermission = GrantPermissionRule.grant(Manifest.permission.ACCESS_FINE_LOCATION); - @Rule - public GrantPermissionRule coarseLocationPermission = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION); - @Rule - public GrantPermissionRule writeStoragePermission = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE); - - @Rule - public ActivityTestRule mRule = new ActivityTestRule(TrackManager.class) { - @Override - protected void beforeActivityLaunched() { - // Skip cool intro - SharedPreferences dtPrefs = PreferenceManager - .getDefaultSharedPreferences(getInstrumentation().getTargetContext()); - dtPrefs.edit().putBoolean(OSMTracker.Preferences.KEY_DISPLAY_APP_INTRO, false).apply(); - } - }; - - @Test - public void downloadLayoutTest() { - deleteLayoutsDirectory(); - - TestUtils.setLayoutsTestingRepository(); - - String layoutName = "abc"; - - navigateToAvailableLayouts(); - - clickButtonsToDownloadLayout(layoutName); - - makePostDownloadAssertions(layoutName); - } - - - public void deleteLayoutsDirectory(){ - try { - FileUtils.deleteDirectory(TestUtils.getLayoutsDirectory()); - }catch (Exception e){ - e.printStackTrace(); - fail(); - } - } - - - /** - * Assuming being in TrackManager - */ - public void navigateToAvailableLayouts() { - // Open options menu in the Action Bar - openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); - // Click on "Settings" in this menu - onView(withText(TestUtils.getStringResource(R.string.menu_settings))).perform(click()); - // Click on "Buttons presets" settings - onData(withTitleText(TestUtils.getStringResource(R.string.prefs_ui_buttons_layout))).perform(scrollTo(), click()); - // Wait for "+" to be visible - onView(isRoot()).perform(waitForView(R.id.launch_available, WAIT_VIEW_TIMEOUT)); - // Perform a click action on the "+" button - onView(withId(R.id.launch_available)).perform(click()); - } - - - /** - * Check the new layouts appears as a new option - * Select the layout and check its buttons are shown when tracking - * @param layoutName - */ - private void makePostDownloadAssertions(String layoutName) { - Espresso.pressBack(); - - // Check the layout appears as a new option in AvailableLayouts - onView(withText(layoutName.toLowerCase())).check(matches(isDisplayed())); - - // Select the layout - onView(withText(layoutName.toLowerCase())).perform(click()); - - // Go to TrackLogger - Espresso.pressBack(); - Espresso.pressBack(); - onView(withId(R.id.trackmgr_fab)).perform(click()); - - // Check the buttons are loaded correctly - String expectedButtonsLabels[] = new String[]{"A", "B", "C"}; - for(String label : expectedButtonsLabels) - onView(withText(label)).check(matches(isDisplayed())); - - } - - - private void clickButtonsToDownloadLayout(String layoutName) { - onView(withText(layoutName)).perform(click()); - - // Catch languages available dialog that shows up when the cell phone is not in English - if (! Locale.getDefault().getLanguage().equalsIgnoreCase("en")) { - onView(withText("English")).perform(click()); - } - - onView(withText(TestUtils.getStringResource(R.string.available_layouts_description_dialog_positive_confirmation))). - perform(click()); - - TestUtils.checkToastIsShownWith(TestUtils.getStringResource(R.string.available_layouts_successful_download)); - } + private final int WAIT_VIEW_TIMEOUT = 5000; + + @Rule + public GrantPermissionRule fineLocationPermission = GrantPermissionRule.grant(Manifest.permission.ACCESS_FINE_LOCATION); + @Rule + public GrantPermissionRule coarseLocationPermission = GrantPermissionRule.grant(Manifest.permission.ACCESS_COARSE_LOCATION); + @Rule + public GrantPermissionRule writeStoragePermission = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE); + + public ActivityScenario activity; + + @Before + public void setUp() { + // Skip cool intro + SharedPreferences dtPrefs = PreferenceManager + .getDefaultSharedPreferences(getInstrumentation().getTargetContext()); + dtPrefs.edit().putBoolean(OSMTracker.Preferences.KEY_DISPLAY_APP_INTRO, false).apply(); + // Launch activity + activity = ActivityScenario.launch(TrackManager.class); + activity.moveToState(Lifecycle.State.RESUMED); + } + + @After + public void tearDown() { + activity.close(); + } + + @Test + public void downloadLayoutTest() { + deleteLayoutsDirectory(); + + TestUtils.setLayoutsTestingRepository(); + + String layoutName = "abc"; + + navigateToAvailableLayouts(); + + clickButtonsToDownloadLayout(layoutName); + + makePostDownloadAssertions(layoutName); + } + + + public void deleteLayoutsDirectory() { + try { + FileUtils.deleteDirectory(TestUtils.getLayoutsDirectory()); + } catch (Exception e) { + e.printStackTrace(); + fail(); + } + } + + + /** + * Assuming being in TrackManager + */ + public void navigateToAvailableLayouts() { + // Open options menu in the Action Bar + openActionBarOverflowOrOptionsMenu(getInstrumentation().getTargetContext()); + // Click on "Settings" in this menu + onView(withText(TestUtils.getStringResource(R.string.menu_settings))).perform(click()); + // Click on "Buttons presets" settings + onView(ViewMatchers.withId(androidx.preference.R.id.recycler_view)) + .perform(RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(withText(R.string.prefs_ui_buttons_layout)), + click() + )); + // Wait for "+" to be visible + onView(isRoot()).perform(waitForView(R.id.launch_available, WAIT_VIEW_TIMEOUT)); + // Perform a click action on the "+" button + onView(withId(R.id.launch_available)).perform(click()); + } + + + /** + * Check the new layouts appears as a new option + * Select the layout and check its buttons are shown when tracking + * + * @param layoutName layout name + */ + private void makePostDownloadAssertions(String layoutName) { + Espresso.pressBack(); + + // Check the layout appears as a new option in AvailableLayouts + onView(withText(layoutName.toLowerCase())).check(matches(isDisplayed())); + + // Select the layout + onView(withText(layoutName.toLowerCase())).perform(click()); + + // Go to TrackLogger + Espresso.pressBack(); + Espresso.pressBack(); + onView(withId(R.id.trackmgr_fab)).perform(click()); + + // Check the buttons are loaded correctly + String[] expectedButtonsLabels = new String[]{"A", "B", "C"}; + for (String label : expectedButtonsLabels) + onView(withText(label)).check(matches(isDisplayed())); + + } + + + private void clickButtonsToDownloadLayout(String layoutName) { + onView(withText(layoutName)).perform(click()); + + // Catch languages available dialog that shows up when the cell phone is not in English + if (!Locale.getDefault().getLanguage().equalsIgnoreCase("en")) { + onView(withText("English")).perform(click()); + } + + onView(withText(TestUtils.getStringResource(R.string.available_layouts_description_dialog_positive_confirmation))). + perform(click()); + + TestUtils.checkToastIsShownWith(TestUtils.getStringResource(R.string.available_layouts_successful_download)); + } } \ No newline at end of file diff --git a/app/src/androidTest/java/net/osmtracker/layouts/RepositorySettingsDialogTest.java b/app/src/androidTest/java/net/osmtracker/layouts/RepositorySettingsDialogTest.java index 607587fc0..bac3b7d7c 100644 --- a/app/src/androidTest/java/net/osmtracker/layouts/RepositorySettingsDialogTest.java +++ b/app/src/androidTest/java/net/osmtracker/layouts/RepositorySettingsDialogTest.java @@ -16,101 +16,115 @@ import static net.osmtracker.util.TestUtils.getStringResource; import static org.hamcrest.core.IsNot.not; +import android.view.View; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; import androidx.test.espresso.ViewAssertion; -import androidx.test.rule.ActivityTestRule; import net.osmtracker.OSMTracker; import net.osmtracker.R; import net.osmtracker.activity.AvailableLayouts; import org.hamcrest.Matcher; -import org.junit.Rule; +import org.junit.After; +import org.junit.Before; import org.junit.Test; public class RepositorySettingsDialogTest { - @Rule - public ActivityTestRule mRule = new ActivityTestRule<>(AvailableLayouts.class); - - - @Test - public void testToggleBehaviour(){ - onView(withId(R.id.github_config)).perform(click()); - - onView(withId(R.id.default_server)).perform(click(), closeSoftKeyboard()); - checkStateAfterToggle(R.id.default_server, R.id.custom_server); - checkTextFieldsState(not(isEnabled())); - checkTextFieldsDefaultValues(); - - onView(withId(R.id.custom_server)).perform(click(), closeSoftKeyboard()); - checkStateAfterToggle(R.id.custom_server, R.id.default_server); - checkTextFieldsState(isEnabled()); - } - - @Test - public void testRepositoryValidation(){ - String validUser = OSMTracker.Preferences.VAL_GITHUB_USERNAME; - String validRepository = OSMTracker.Preferences.VAL_REPOSITORY_NAME; - String validBranch = OSMTracker.Preferences.VAL_BRANCH_NAME; - String invalidBranch = "NONE"; - - checkRepositoryValidity(validUser,validRepository,validBranch, true); - checkRepositoryValidity(validUser,validRepository,invalidBranch, false); - } - - - public void checkStateAfterToggle(int expectedActiveId, int expectedInactiveId){ - onView(withId(expectedActiveId)).check(matches(not(isEnabled()))); - onView(withId(expectedActiveId)).check(matches(isChecked())); - onView(withId(expectedInactiveId)).check(matches(not(isChecked()))); - onView(withId(expectedInactiveId)).check(matches(isEnabled())); - } - - public void checkRepositoryValidity(String user, String repo, String branch, boolean isValid) { - onView(withId(R.id.github_config)).perform(click()); - - onView(withId(R.id.custom_server)).perform(click(), closeSoftKeyboard()); - - onView(withId(R.id.github_username)).perform(clearText(), typeText(user), closeSoftKeyboard()); - onView(withId(R.id.repository_name)).perform(clearText(), typeText(repo), closeSoftKeyboard()); - onView(withId(R.id.branch_name)).perform(clearText(), typeText(branch), closeSoftKeyboard()); - - onView(withText(getStringResource(R.string.menu_save))).perform(click()); - - String expectedMessage = (isValid) ? getStringResource(R.string.github_repository_settings_valid_server) : - getStringResource(R.string.github_repository_settings_invalid_server); - - checkToastIsShownWith(expectedMessage); - - ViewAssertion expectedDialogState = (isValid) ? doesNotExist() : matches(isDisplayed()); - checkDialogState(expectedDialogState); - } - - /** - * Check if the dialog is shown by looking for its title on the screen - */ - private void checkDialogState(ViewAssertion assertion) { - onView(withText(getStringResource(R.string.prefs_ui_github_repository_settings))).check(assertion); - } - - /** - * Check that the text fields values match the expected default ones - */ - private void checkTextFieldsDefaultValues() { - onView(withId(R.id.repository_name)).check(matches(withText(OSMTracker.Preferences.VAL_REPOSITORY_NAME))); - onView(withId(R.id.branch_name)).check(matches(withText(OSMTracker.Preferences.VAL_BRANCH_NAME))); - onView(withId(R.id.github_username)).check(matches(withText(OSMTracker.Preferences.VAL_GITHUB_USERNAME))); - } - - /** - * @param matcher can be isEnabled or not(isEnabled()) or any matcher - */ - public void checkTextFieldsState(Matcher matcher){ - - onView(withId(R.id.github_username)).check(matches(matcher)); - onView(withId(R.id.repository_name)).check(matches(matcher)); - onView(withId(R.id.branch_name)).check(matches(matcher)); - } + public ActivityScenario activity; + + @Before + public void setUp() { + // Launch activity + activity = ActivityScenario.launch(AvailableLayouts.class); + activity.moveToState(Lifecycle.State.RESUMED); + } + + @After + public void tearDown() { + activity.close(); + } + + @Test + public void testToggleBehaviour() { + onView(withId(R.id.github_config)).perform(click()); + + onView(withId(R.id.default_server)).perform(click(), closeSoftKeyboard()); + checkStateAfterToggle(R.id.default_server, R.id.custom_server); + checkTextFieldsState(not(isEnabled())); + checkTextFieldsDefaultValues(); + + onView(withId(R.id.custom_server)).perform(click(), closeSoftKeyboard()); + checkStateAfterToggle(R.id.custom_server, R.id.default_server); + checkTextFieldsState(isEnabled()); + } + + @Test + public void testRepositoryValidation() { + String validUser = OSMTracker.Preferences.VAL_GITHUB_USERNAME; + String validRepository = OSMTracker.Preferences.VAL_REPOSITORY_NAME; + String validBranch = OSMTracker.Preferences.VAL_BRANCH_NAME; + String invalidBranch = "NONE"; + + checkRepositoryValidity(validUser, validRepository, validBranch, true); + checkRepositoryValidity(validUser, validRepository, invalidBranch, false); + } + + + public void checkStateAfterToggle(int expectedActiveId, int expectedInactiveId) { + onView(withId(expectedActiveId)).check(matches(not(isEnabled()))); + onView(withId(expectedActiveId)).check(matches(isChecked())); + onView(withId(expectedInactiveId)).check(matches(not(isChecked()))); + onView(withId(expectedInactiveId)).check(matches(isEnabled())); + } + + public void checkRepositoryValidity(String user, String repo, String branch, boolean isValid) { + onView(withId(R.id.github_config)).perform(click()); + + onView(withId(R.id.custom_server)).perform(click(), closeSoftKeyboard()); + + onView(withId(R.id.github_username)).perform(clearText(), typeText(user), closeSoftKeyboard()); + onView(withId(R.id.repository_name)).perform(clearText(), typeText(repo), closeSoftKeyboard()); + onView(withId(R.id.branch_name)).perform(clearText(), typeText(branch), closeSoftKeyboard()); + + onView(withText(getStringResource(R.string.menu_save))).perform(click()); + + String expectedMessage = (isValid) ? getStringResource(R.string.github_repository_settings_valid_server) : + getStringResource(R.string.github_repository_settings_invalid_server); + + checkToastIsShownWith(expectedMessage); + + ViewAssertion expectedDialogState = (isValid) ? doesNotExist() : matches(isDisplayed()); + checkDialogState(expectedDialogState); + } + + /** + * Check if the dialog is shown by looking for its title on the screen + */ + private void checkDialogState(ViewAssertion assertion) { + onView(withText(getStringResource(R.string.prefs_ui_github_repository_settings))).check(assertion); + } + + /** + * Check that the text fields values match the expected default ones + */ + private void checkTextFieldsDefaultValues() { + onView(withId(R.id.repository_name)).check(matches(withText(OSMTracker.Preferences.VAL_REPOSITORY_NAME))); + onView(withId(R.id.branch_name)).check(matches(withText(OSMTracker.Preferences.VAL_BRANCH_NAME))); + onView(withId(R.id.github_username)).check(matches(withText(OSMTracker.Preferences.VAL_GITHUB_USERNAME))); + } + + /** + * @param matcher can be isEnabled or not(isEnabled()) or any matcher + */ + public void checkTextFieldsState(Matcher matcher) { + + onView(withId(R.id.github_username)).check(matches(matcher)); + onView(withId(R.id.repository_name)).check(matches(matcher)); + onView(withId(R.id.branch_name)).check(matches(matcher)); + } } diff --git a/app/src/androidTest/java/net/osmtracker/util/TestUtils.java b/app/src/androidTest/java/net/osmtracker/util/TestUtils.java index 2c733c75f..18d94d3a2 100644 --- a/app/src/androidTest/java/net/osmtracker/util/TestUtils.java +++ b/app/src/androidTest/java/net/osmtracker/util/TestUtils.java @@ -4,13 +4,13 @@ import android.content.Context; import android.content.SharedPreferences; -import android.preference.PreferenceManager; +import androidx.preference.PreferenceManager; import androidx.test.platform.app.InstrumentationRegistry; import net.osmtracker.OSMTracker; -import net.osmtracker.activity.Preferences; import net.osmtracker.data.Mocks; +import net.osmtracker.db.DataHelper; import java.io.File; import java.io.FileWriter; @@ -66,7 +66,7 @@ public static void injectMockLayout(String layoutName, String ISOLangCode) { writeToFile(newLayout, Mocks.MOCK_LAYOUT_CONTENT); // Create the icons directory - File iconsDir = createDirectory(layoutsDir, layoutName + Preferences.ICONS_DIR_SUFFIX); + File iconsDir = createDirectory(layoutsDir, layoutName + DataHelper.LAYOUTS_ICONS_DIR_SUFFIX); // And put some mock files inside int pngsToCreate = 4; @@ -109,7 +109,7 @@ public static File getAppDirectory(){ */ public static File getLayoutsDirectory(){ String appDirectory = getAppDirectory().getAbsolutePath(); - File layoutsDirectory = new File(appDirectory + File.separator + Preferences.LAYOUTS_SUBDIR); + File layoutsDirectory = new File(appDirectory + File.separator + DataHelper.LAYOUTS_SUBDIR); layoutsDirectory.mkdirs(); return layoutsDirectory; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dc8627d4..071c23b80 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,10 +52,16 @@ android:launchMode="singleTop" /> + android:exported="false" + android:label="@string/prefs" + android:theme="@style/AppTheme" /> + @@ -64,6 +70,8 @@ android:label="@string/osm_upload" android:exported="true"> + diff --git a/app/src/main/java/net/osmtracker/OSMTracker.java b/app/src/main/java/net/osmtracker/OSMTracker.java index 046a4d23a..3accb77c9 100644 --- a/app/src/main/java/net/osmtracker/OSMTracker.java +++ b/app/src/main/java/net/osmtracker/OSMTracker.java @@ -24,6 +24,7 @@ public static final class Preferences { public final static String KEY_GPS_LOGGING_INTERVAL = "gps.logging.interval"; public final static String KEY_GPS_LOGGING_MIN_DISTANCE = "gps.logging.min_distance"; public final static String KEY_USE_BAROMETER = "gpx.use_barometer"; + public final static String KEY_USE_NOTES = "gpx.notes"; public final static String KEY_OUTPUT_FILENAME = "gpx.filename"; public final static String KEY_OUTPUT_FILENAME_LABEL = "gpx.filename.label"; public final static String KEY_OUTPUT_ACCURACY = "gpx.accuracy"; @@ -40,6 +41,7 @@ public static final class Preferences { public final static String KEY_UI_MAP_TILE = "ui.map.tile"; public final static String KEY_SOUND_ENABLED = "sound_enabled"; public final static String KEY_UI_ORIENTATION = "ui.orientation"; + public final static String KEY_OSM_TRACK_VISIBILITY = "osm.track.visibility"; public final static String KEY_OSM_OAUTH2_ACCESSTOKEN = "osm.oauth2.accesstoken"; public final static String KEY_OSM_OAUTH_CLEAR_DATA = "osm.oauth.clear-data"; @@ -60,6 +62,7 @@ public static final class Preferences { public final static String VAL_GPS_LOGGING_INTERVAL = "0"; public final static String VAL_GPS_LOGGING_MIN_DISTANCE = "0"; public final static boolean VAL_USE_BAROMETER = false; + public final static String VAL_USE_NOTES = "both"; public final static String VAL_OUTPUT_FILENAME_NAME = "name"; public final static String VAL_OUTPUT_FILENAME_NAME_DATE = "name_date"; @@ -90,6 +93,8 @@ public static final class Preferences { public final static boolean VAL_UI_DISPLAYTRACK_OSM = false; public final static boolean VAL_UI_DISPLAY_KEEP_ON = true; public final static boolean VAL_SOUND_ENABLED = true; + // Matches Track.OSMVisibility.Identifiable; + public final static String VAL_OSM_TRACK_VISIBILITY = "Identifiable"; public final static String VAL_UI_ORIENTATION_NONE = "none"; public final static String VAL_UI_ORIENTATION_PORTRAIT = "portrait"; public final static String VAL_UI_ORIENTATION_LANDSCAPE = "landscape"; @@ -128,7 +133,16 @@ public static final class Preferences { * Intent for deleting a previously tracked waypoint */ public final static String INTENT_DELETE_WP = OSMTracker.PACKAGE_NAME + ".intent.DELETE_WP"; - + + /** + * Intent for tracking a note + */ + public final static String INTENT_TRACK_NOTE = OSMTracker.PACKAGE_NAME + ".intent.TRACK_NOTE"; + /** + * Intent for updating a previously tracked waypoint + */ + public final static String INTENT_UPDATE_NOTE = OSMTracker.PACKAGE_NAME + ".intent.UPDATE_NOTE"; + /** * Intent to start tracking */ diff --git a/app/src/main/java/net/osmtracker/activity/About.java b/app/src/main/java/net/osmtracker/activity/About.java index 84aa7804c..9ab141da0 100644 --- a/app/src/main/java/net/osmtracker/activity/About.java +++ b/app/src/main/java/net/osmtracker/activity/About.java @@ -15,12 +15,12 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.os.Bundle; import android.os.Environment; -import android.preference.PreferenceManager; import android.view.View; import android.view.View.OnClickListener; -import android.widget.Button; import android.widget.TextView; +import androidx.preference.PreferenceManager; + import java.io.File; /** diff --git a/app/src/main/java/net/osmtracker/activity/AvailableLayouts.java b/app/src/main/java/net/osmtracker/activity/AvailableLayouts.java index eb215767c..c4ad46b18 100644 --- a/app/src/main/java/net/osmtracker/activity/AvailableLayouts.java +++ b/app/src/main/java/net/osmtracker/activity/AvailableLayouts.java @@ -13,7 +13,6 @@ import android.net.NetworkInfo; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; @@ -26,6 +25,8 @@ import android.widget.TextView; import android.widget.Toast; +import androidx.preference.PreferenceManager; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; diff --git a/app/src/main/java/net/osmtracker/activity/ButtonsPresets.java b/app/src/main/java/net/osmtracker/activity/ButtonsPresets.java index 2d5f864dd..50438c96a 100644 --- a/app/src/main/java/net/osmtracker/activity/ButtonsPresets.java +++ b/app/src/main/java/net/osmtracker/activity/ButtonsPresets.java @@ -11,9 +11,10 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + import android.util.Log; import android.view.ContextMenu; import android.view.Menu; @@ -26,6 +27,7 @@ import net.osmtracker.OSMTracker; import net.osmtracker.R; +import net.osmtracker.db.DataHelper; import net.osmtracker.layout.DownloadCustomLayoutTask; import net.osmtracker.util.CustomLayoutsUtils; import net.osmtracker.util.FileSystemUtils; @@ -41,7 +43,7 @@ public class ButtonsPresets extends Activity { @SuppressWarnings("unused") - private static final String TAG = Preferences.class.getSimpleName(); + private static final String TAG = ButtonsPresets.class.getSimpleName(); final private int RC_WRITE_PERMISSION = 1; @@ -108,7 +110,7 @@ private void initializeAttributes(){ private void listLayouts(LinearLayout rootLayout){ File layoutsDir = new File(this.getExternalFilesDir(null), storageDir + - File.separator + Preferences.LAYOUTS_SUBDIR + File.separator); + File.separator + DataHelper.LAYOUTS_SUBDIR + File.separator); int AT_START = 0; //the position to insert the view at int fontSize = 20; if (layoutsDir.exists() && layoutsDir.canRead()) { @@ -116,7 +118,7 @@ private void listLayouts(LinearLayout rootLayout){ String[] layoutFiles = layoutsDir.list(new FilenameFilter() { @Override public boolean accept(File dir, String filename) { - return filename.endsWith(Preferences.LAYOUT_FILE_EXTENSION); + return filename.endsWith(DataHelper.LAYOUT_FILE_EXTENSION); } }); //Remove all the layouts @@ -272,10 +274,10 @@ protected void onPostExecute(Boolean status){ @Override public void onClick(DialogInterface dialog, int which) { String fileName = layoutsFileNames.get(checkboxHeld.getText()); - String rootDir = storageDir + File.separator + Preferences.LAYOUTS_SUBDIR + File.separator; + String rootDir = storageDir + File.separator + DataHelper.LAYOUTS_SUBDIR + File.separator; File fileToDelete = new File(externalFilesDir, rootDir + fileName); String iconDirName = fileName.substring(0, fileName.length() - CustomLayoutsUtils.LAYOUT_EXTENSION_ISO.length()) - + Preferences.ICONS_DIR_SUFFIX; + + DataHelper.LAYOUTS_ICONS_DIR_SUFFIX; File iconDirToDelete = new File(externalFilesDir, rootDir + iconDirName); boolean successfulDeletion = FileSystemUtils.delete(fileToDelete, false); @@ -312,7 +314,7 @@ public void onClick(DialogInterface dialog, int which) { * Example: given "foo_es.xml" return only "es" */ private String getIso(String layoutName){ - String tmp = layoutName.substring(0, layoutName.length() - Preferences.LAYOUT_FILE_EXTENSION.length()); + String tmp = layoutName.substring(0, layoutName.length() - DataHelper.LAYOUT_FILE_EXTENSION.length()); String iso = ""; for (int i=tmp.length() - AvailableLayouts.ISO_CHARACTER_LENGTH; i { + String newName = editNoteName.getText().toString(); + dataHelper.updateNote(trackId, uuid, newName); + refreshData(); + alert.dismiss(); + }); + + // Delete note + buttonDelete.setOnClickListener(v -> new AlertDialog.Builder(this) + .setTitle(R.string.delete_note_confirm_dialog_title) + .setMessage(R.string.delete_note_confirm_dialog_msg) + .setPositiveButton(R.string.delete_note_confirm_bt_ok, (dialog, which) -> { + dataHelper.deleteNote(uuid); + refreshData(); + alert.dismiss(); + }) + .setNegativeButton(R.string.delete_note_confirm_bt_cancel, + (dialog, which) -> dialog.dismiss()) + .show()); + + // Upload note text to OpenStreetMap + buttonOSMUpload.setOnClickListener(v -> { + uploadNoteToOSM(noteId); + alert.dismiss(); + }); + + // Cancel button + buttonCancel.setOnClickListener(v -> alert.dismiss()); + + alert.setView(editNoteDialog); + alert.show(); + } + + /** + * Extracts note data from DB and launches the OSM Note Upload activity. + */ + private void uploadNoteToOSM(long noteId) { + // Query the specific note to get latest Lat/Lon + Cursor cursor = getContentResolver().query( + TrackContentProvider.noteUri(noteId), + null,null,null,null); + + if (cursor != null && cursor.moveToFirst()) { + String noteText = cursor.getString( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_NAME)); + double lat = cursor.getDouble( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_LATITUDE)); + double lon = cursor.getDouble( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_LONGITUDE)); + + Intent intent = new Intent(this, OpenStreetMapNotesUpload.class); + intent.putExtra("noteId", noteId); + intent.putExtra("noteContent", noteText); + intent.putExtra("appName", getString(R.string.app_name)); + intent.putExtra("latitude", lat); + intent.putExtra("longitude", lon); + + // Retrieve app version number + try { + PackageInfo pi = getPackageManager().getPackageInfo(getPackageName(), 0); + intent.putExtra("version", pi.versionName); + } catch (PackageManager.NameNotFoundException e) { + // Ignore + Log.d(TAG, "Package name not found", e); + } + + cursor.close(); + startActivity(intent); + } + } +} diff --git a/app/src/main/java/net/osmtracker/activity/OpenStreetMapNotesUpload.java b/app/src/main/java/net/osmtracker/activity/OpenStreetMapNotesUpload.java new file mode 100644 index 000000000..c55a147ff --- /dev/null +++ b/app/src/main/java/net/osmtracker/activity/OpenStreetMapNotesUpload.java @@ -0,0 +1,215 @@ +package net.osmtracker.activity; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; + +import net.openid.appauth.AuthorizationException; +import net.openid.appauth.AuthorizationRequest; +import net.openid.appauth.AuthorizationResponse; +import net.openid.appauth.AuthorizationService; +import net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.ResponseTypeValues; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; +import net.osmtracker.osm.OpenStreetMapConstants; +import net.osmtracker.osm.UploadToOpenStreetMapNotesTask; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + *

Uploads a note on OSM using the API and OAuth authentication.

+ * + *

This activity may be called twice during a single + * upload cycle: First to start the upload, then a second + * time when the user has authenticated using the browser.

+ * + * @author Most of the code was made by Nicolas Guillaumin, adapted by Jose Andrés Vargas Serrano + */ +public class OpenStreetMapNotesUpload extends AppCompatActivity { + + private static final String TAG = OpenStreetMapNotesUpload.class.getSimpleName(); + + private long noteId; + + private double latitude; + private double longitude; + + private TextView noteContentView; + private TextView noteFooterView; + + /** URL that the browser will call once the user is authenticated */ + public final static String OAUTH2_CALLBACK_URL = "osmtracker://osm-upload/oath2-completed/"; + private AuthorizationService authService; + private ActivityResultLauncher authLauncher; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Register the launcher + authLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + // This replaces the logic previously in onActivityResult + Intent data = result.getData(); + // RC_AUTH logic + if (data != null) { + AuthorizationResponse resp = AuthorizationResponse.fromIntent(data); + AuthorizationException ex = AuthorizationException.fromIntent(data); + + if (resp != null) { + exchangeAuthorizationCode(resp); + } else { + Log.e(TAG, "Authorization failed: " + (ex != null ? ex.getMessage() : "Unknown error")); + Toast.makeText(this, R.string.osm_upload_oauth_failed, Toast.LENGTH_SHORT).show(); + } + } + } + ); + + + setContentView(R.layout.osm_note_upload); + setTitle(R.string.osm_note_upload); + noteContentView = findViewById(R.id.wplist_item_name); + noteFooterView = findViewById(R.id.osm_note_footer); + + // Read and cache extras + Bundle extras = getIntent().getExtras(); + if (extras == null) { + Log.e(TAG, "Missing extras for note upload."); + finish(); + return; + } + + String initialNoteText = extras.getString("noteContent", ""); + String appName = extras.getString("appName", getString(R.string.app_name)); + String version = extras.getString("version", ""); + + if (extras.containsKey("noteId")) noteId = extras.getLong("noteId"); + if (extras.containsKey("latitude")) latitude = extras.getDouble("latitude"); + if (extras.containsKey("longitude")) longitude = extras.getDouble("longitude"); + + // fill UI with note content and note footer + noteContentView.setText(initialNoteText); + noteFooterView.setText(getString(R.string.osm_note_footer, appName, version)); + + final Button btnOk = findViewById(R.id.osm_note_upload_button_ok); + btnOk.setOnClickListener(v -> startUpload(noteId)); + final Button btnCancel = findViewById(R.id.osm_note_upload_button_cancel); + btnCancel.setOnClickListener(v -> finish()); + + } + + + /** + * Either starts uploading directly if we are authenticated against OpenStreetMap, + * or ask the user to authenticate via the browser. + */ + private void startUpload(long noteId) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + String accessToken = prefs.getString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, null); + + if (accessToken != null && !accessToken.isEmpty()) { + // STATE: AUTHORIZED. Re-use saved token + Log.d(TAG, "Token found, proceeding to upload note to OSM."); + uploadToOsm(accessToken, noteId); + } else { + // STATE: UNAUTHORIZED. Open browser and request token + Log.d(TAG, "No token found, requesting authorization."); + requestOsmAuth(); + } + } + /* + * Init Authorization request workflow. Launches browser to request authorization. + */ + public void requestOsmAuth() { + // Authorization service configuration + AuthorizationServiceConfiguration serviceConfig = new AuthorizationServiceConfiguration( + Uri.parse(OpenStreetMapConstants.OAuth2.Urls.AUTHORIZATION_ENDPOINT), + Uri.parse(OpenStreetMapConstants.OAuth2.Urls.TOKEN_ENDPOINT)); + + // Obtaining an authorization code + AuthorizationRequest authRequest = new AuthorizationRequest.Builder( + serviceConfig, + OpenStreetMapConstants.OAuth2.CLIENT_ID, + ResponseTypeValues.CODE, + Uri.parse(OAUTH2_CALLBACK_URL)) + .setScope(OpenStreetMapConstants.OAuth2.SCOPE) + .build(); + + // Start activity. + authService = new AuthorizationService(this); + Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); + //when done onActivityResult will be called. + // Use the launcher instead of startActivityForResult + authLauncher.launch(authIntent); + } + + private void exchangeAuthorizationCode(AuthorizationResponse resp) { + authService.performTokenRequest(resp.createTokenExchangeRequest(), (tokenResp, tokenEx) -> { + if (tokenResp != null && tokenResp.accessToken != null) { + // STATE: TRANSITION TO AUTHORIZED + persistToken(tokenResp.accessToken); + uploadToOsm(tokenResp.accessToken, noteId); + } else { + Log.e(TAG, "Token exchange failed"); + } + }); + } + + private void persistToken(String token) { + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, token) + .apply(); + } + + /** + * Uploads notes to OSM. + */ + public void uploadToOsm(String accessToken, long noteId) { + String noteText = noteContentView.getText().toString(); + String footer = noteFooterView.getText().toString(); + if (!footer.isEmpty()) { + noteText = noteText + "\n\n" + footer; + } + + // Final variables for the background thread + final String finalNoteText = noteText; + + // This replaces the deprecated AsyncTask.execute() + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.execute(() -> { + try { + new UploadToOpenStreetMapNotesTask( + OpenStreetMapNotesUpload.this, + accessToken, + noteId, + finalNoteText, + latitude, + longitude + ).run(); + } catch (Exception e) { + Log.e(TAG, "Error during OSM Note upload", e); + runOnUiThread(() -> + Toast.makeText(this, R.string.osm_upload_error, Toast.LENGTH_SHORT).show() + ); + } finally { + executor.shutdown(); + } + }); + } + +} diff --git a/app/src/main/java/net/osmtracker/activity/OpenStreetMapUpload.java b/app/src/main/java/net/osmtracker/activity/OpenStreetMapUpload.java index 3b7df4b85..aadb1e49d 100644 --- a/app/src/main/java/net/osmtracker/activity/OpenStreetMapUpload.java +++ b/app/src/main/java/net/osmtracker/activity/OpenStreetMapUpload.java @@ -6,7 +6,6 @@ import android.database.Cursor; import android.net.Uri; import android.os.Bundle; -import android.preference.PreferenceManager; import android.util.Log; import android.view.View; import android.view.View.OnClickListener; @@ -14,6 +13,8 @@ import android.widget.Button; import android.widget.Toast; +import androidx.preference.PreferenceManager; + import net.openid.appauth.AuthorizationException; import net.openid.appauth.AuthorizationRequest; import net.openid.appauth.AuthorizationResponse; diff --git a/app/src/main/java/net/osmtracker/activity/Preferences.java b/app/src/main/java/net/osmtracker/activity/Preferences.java index 6ccb65aed..d1b2cd54d 100644 --- a/app/src/main/java/net/osmtracker/activity/Preferences.java +++ b/app/src/main/java/net/osmtracker/activity/Preferences.java @@ -1,241 +1,366 @@ package net.osmtracker.activity; -import java.io.File; -import java.io.FilenameFilter; - -import net.osmtracker.OSMTracker; -import net.osmtracker.R; - -import android.Manifest; -import android.app.AlertDialog; import android.content.Intent; import android.content.SharedPreferences; -import android.content.SharedPreferences.Editor; -import android.content.pm.PackageManager; import android.os.Bundle; -import android.os.Environment; - -import android.preference.EditTextPreference; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.Preference.OnPreferenceClickListener; -import android.preference.PreferenceActivity; -import android.preference.PreferenceManager; -import android.provider.Settings; -import android.text.Editable; -import android.text.TextWatcher; +import android.text.TextUtils; import android.widget.Button; -import android.widget.EditText; -import android.widget.ListView; +import android.widget.Toast; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentManager; +import androidx.preference.EditTextPreference; +import androidx.preference.EditTextPreferenceDialogFragmentCompat; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; + +import java.io.File; +import java.util.Objects; /** - * Manages preferences screen. - * - * @author Nicolas Guillaumin - * + * Manages preferences screen */ -public class Preferences extends PreferenceActivity { - - @SuppressWarnings("unused") - private static final String TAG = Preferences.class.getSimpleName(); - - /** - * Directory containing user layouts, relative to storage dir. - */ - public static final String LAYOUTS_SUBDIR = "layouts"; - - /** - * File extension for layout files - */ - - public static final String LAYOUT_FILE_EXTENSION = ".xml"; - - /** - * The suffix that must be added to the layout's name for getting its icons directory - * Example: water_supply <- layout name - * water_supply_icons <- icon directory - */ - - public static final String ICONS_DIR_SUFFIX = "_icons"; +public class Preferences extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.preferences); - ListView listView = getListView(); - listView.setFitsSystemWindows(true); - listView.setClipToPadding(false); - listView.setPadding(0, 48, 0, 0); - - // Set summary of some preferences to their actual values - // and register a change listener to set again the summary in case of change - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - - // Explicit execution of buttons presets window - Preference buttonLayoutPref = findPreference("prefs_ui_buttons_layout"); - if (buttonLayoutPref != null) { - buttonLayoutPref.setOnPreferenceClickListener(preference -> { - Intent intent = new Intent(this, ButtonsPresets.class); - startActivity(intent); - return true; - }); + setContentView(R.layout.settings_activity); + if (savedInstanceState == null) { + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new SettingsFragment()) + .commit(); + } + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); } + } - // External storage directory - EditTextPreference storageDirPref = (EditTextPreference) findPreference(OSMTracker.Preferences.KEY_STORAGE_DIR); - storageDirPref.setSummary(prefs.getString(OSMTracker.Preferences.KEY_STORAGE_DIR, OSMTracker.Preferences.VAL_STORAGE_DIR)); - storageDirPref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Ensure there is always a leading slash - if (! ((String) newValue).startsWith(File.separator)) { - newValue = File.separator + (String) newValue; + public static class SettingsFragment extends PreferenceFragmentCompat { + + private static final String EXTRA_DEFAULT_VALUE = "DEFAULT_VALUE"; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.preferences, rootKey); + SharedPreferences prefs = + PreferenceManager.getDefaultSharedPreferences(requireContext()); + + // General settings + setupVoiceRecDuration(); + // Notes + setupListPreference( + OSMTracker.Preferences.KEY_USE_NOTES, + getString(R.string.prefs_notes_summary) + ); + // OSM track visibility + setupListPreference( + OSMTracker.Preferences.KEY_OSM_TRACK_VISIBILITY, + getString(R.string.prefs_osm_visibility_summary) + ); + setupOSMAuthClearData(prefs); + + // GPS Settings + //Open Android GPS Settings screen + setupPreferenceNavigation( + OSMTracker.Preferences.KEY_GPS_OSSETTINGS, + new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)); + //GPSLogging Interval + setupEditTextNum( + OSMTracker.Preferences.KEY_GPS_LOGGING_INTERVAL, + getString(R.string.prefs_gps_logging_interval_seconds), + getString(R.string.prefs_gps_logging_interval_summary), + getString(R.string.prefs_gps_logging_interval_empty), + OSMTracker.Preferences.VAL_GPS_LOGGING_INTERVAL + ); + //GPS Logging Min Distance + setupEditTextNum( + OSMTracker.Preferences.KEY_GPS_LOGGING_MIN_DISTANCE, + getString(R.string.prefs_gps_logging_min_distance_meters), + getString(R.string.prefs_gps_logging_min_distance_summary), + getString(R.string.prefs_gps_logging_min_distance_empty), + OSMTracker.Preferences.VAL_GPS_LOGGING_MIN_DISTANCE + ); + + + // GPX Settings + setupStorageDirectory(); + //Filename + setupListPreference( + OSMTracker.Preferences.KEY_OUTPUT_FILENAME, + getString(R.string.prefs_output_filename_summary) + ); + //Accuracy + setupListPreference( + OSMTracker.Preferences.KEY_OUTPUT_ACCURACY, + getString(R.string.prefs_output_accuracy_summary) + ); + //Compas Heading + setupListPreference( + OSMTracker.Preferences.KEY_OUTPUT_COMPASS, + getString(R.string.prefs_compass_heading_summary) + ); + + // User Interface Settings + // Camera + setupListPreference( + OSMTracker.Preferences.KEY_UI_PICTURE_SOURCE, + getString(R.string.prefs_ui_picture_source_summary) + ); + // App Theme + setupListPreference( + OSMTracker.Preferences.KEY_UI_THEME, + getString(R.string.prefs_theme_summary) + ); + //Explicit execution of buttons presets window + setupPreferenceNavigation( + OSMTracker.Preferences.KEY_UI_BUTTONS_LAYOUT, + new Intent(requireContext(), ButtonsPresets.class)); + //Map tile provider + setupListPreference( + OSMTracker.Preferences.KEY_UI_MAP_TILE, + getString(R.string.prefs_map_tile_summary) + ); + // Screen Orientation + setupListPreference( + OSMTracker.Preferences.KEY_UI_ORIENTATION, + getString(R.string.prefs_ui_orientation_summary) + ); + + } + + /** + * + */ + private void setupStorageDirectory() { + // External storage directory + EditTextPreference storageDirPref = findPreference( + OSMTracker.Preferences.KEY_STORAGE_DIR); + + if (storageDirPref == null) return; + + // Set summary provider + storageDirPref.setSummaryProvider(preference -> { + String val = storageDirPref.getText(); + if (TextUtils.isEmpty(val)) { + return OSMTracker.Preferences.VAL_STORAGE_DIR; + } + return val; + }); + + // Enforce the leading slash + storageDirPref.setOnPreferenceChangeListener((preference, newValue) -> { + String val = newValue.toString().trim(); + // Empty + if (TextUtils.isEmpty(val)) { + Toast.makeText(requireContext(), + R.string.prefs_storage_dir_empty, + Toast.LENGTH_SHORT).show(); + return false; } - // Set summary with the directory value - preference.setSummary((String) newValue); + // Ensure there is always a leading slash + if (!val.startsWith(File.separator)) { + String fixedVal = File.separator + val; + ((EditTextPreference) preference).setText(fixedVal); + return false; //ignores the user input + } return true; - } - }); - - // Voice record duration - Preference pref = findPreference(OSMTracker.Preferences.KEY_VOICEREC_DURATION); - pref.setSummary(prefs.getString(OSMTracker.Preferences.KEY_VOICEREC_DURATION, OSMTracker.Preferences.VAL_VOICEREC_DURATION) + " " + getResources().getString(R.string.prefs_voicerec_duration_seconds)); - pref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Set summary with the number of seconds, following by "seconds" - preference.setSummary(newValue+ " " + getResources().getString(R.string.prefs_voicerec_duration_seconds)); + }); + } + + /** + * Voice record duration: set a custom SummaryProvider + */ + private void setupVoiceRecDuration() { + Preference voiceRec = findPreference(OSMTracker.Preferences.KEY_VOICEREC_DURATION); + + if (voiceRec == null) return; + + voiceRec.setSummaryProvider( + (Preference.SummaryProvider) preference -> { + // Return your combined string + return preference.getEntry() + " " + + getString(R.string.prefs_voicerec_duration_seconds); + }); + } + + /** + * Clear OSM data: Disable if there's no OSM data stored + * + * @param prefs SharedPreferences + */ + private void setupOSMAuthClearData(SharedPreferences prefs) { + + Preference OSMAuthClearData = findPreference( + OSMTracker.Preferences.KEY_OSM_OAUTH_CLEAR_DATA); + + if (OSMAuthClearData == null) return; + + String tokenKey = OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN; + OSMAuthClearData.setEnabled(prefs.contains(tokenKey)); + // Set a Click Listener to show the confirmation dialog + OSMAuthClearData.setOnPreferenceClickListener(preference -> { + new androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle(preference.getTitle()) + .setMessage(R.string.prefs_osm_clear_oauth_data_dialog) + .setIcon(preference.getIcon()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + // User clicked OK: Clear the data + prefs.edit().remove(tokenKey).apply(); + // Disable the button now that data is gone + preference.setEnabled(false); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); return true; - } - }); - - // Use barometer yes/no - pref = findPreference(OSMTracker.Preferences.KEY_USE_BAROMETER); - pref.setSummary(getResources().getString(R.string.prefs_use_barometer_summary)); - - - // Update GPS logging interval summary to the current value - pref = findPreference(OSMTracker.Preferences.KEY_GPS_LOGGING_INTERVAL); - pref.setSummary( - prefs.getString(OSMTracker.Preferences.KEY_GPS_LOGGING_INTERVAL, OSMTracker.Preferences.VAL_GPS_LOGGING_INTERVAL) - + " " + getResources().getString(R.string.prefs_gps_logging_interval_seconds) - + ". " + getResources().getString(R.string.prefs_gps_logging_interval_summary)); - pref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Set summary with the interval and "seconds" - preference.setSummary(newValue - + " " + getResources().getString(R.string.prefs_gps_logging_interval_seconds) - + ". " + getResources().getString(R.string.prefs_gps_logging_interval_summary)); + }); + + } + + /** + * Setup a preference that launches an activity via Intent + * @param preferenceKey The preference key + * @param intent The intent to launch + */ + private void setupPreferenceNavigation(String preferenceKey, Intent intent) { + Preference preference = findPreference(preferenceKey); + + if (preference == null) return; + + preference.setOnPreferenceClickListener(p -> { + startActivity(intent); return true; - } - }); - - // Update GPS min. distance summary to the current value - pref = findPreference(OSMTracker.Preferences.KEY_GPS_LOGGING_MIN_DISTANCE); - pref.setSummary( - prefs.getString(OSMTracker.Preferences.KEY_GPS_LOGGING_MIN_DISTANCE, OSMTracker.Preferences.VAL_GPS_LOGGING_MIN_DISTANCE) - + " " + getResources().getString(R.string.prefs_gps_logging_min_distance_meters) - + ". " + getResources().getString(R.string.prefs_gps_logging_min_distance_summary)); - pref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Set summary with the interval and "seconds" - preference.setSummary(newValue - + " " + getResources().getString(R.string.prefs_gps_logging_min_distance_meters) - + ". " + getResources().getString(R.string.prefs_gps_logging_min_distance_summary)); + }); + } + + /** + * + * @param preferenceKey from OSMTracker.Preferences + * @param valueSuffix appended to the end of the value, shown in the summary + * @param summary static summary to be appended to the end of the summary + * @param validationError in case of empty value + * @param defaultValue value to be used for the reset button + */ + private void setupEditTextNum(String preferenceKey, String valueSuffix, String summary, + String validationError, String defaultValue) { + EditTextPreference numInputPref = findPreference(preferenceKey); + if (numInputPref == null) return; + + // Store default value in Extras so it can be retrieved by the Reset Dialog + numInputPref.getExtras().putString(EXTRA_DEFAULT_VALUE, defaultValue); + + // Set input type to number and move cursor to the end + numInputPref.setOnBindEditTextListener(editText -> { + editText.setInputType(android.text.InputType.TYPE_CLASS_NUMBER); + editText.setSelection(editText.getText().length()); + }); + + // Set summary provider + numInputPref.setSummaryProvider(preference -> { + EditTextPreference editTextPreference = (EditTextPreference) preference; + return editTextPreference.getText() + " " + valueSuffix + ". " + summary; + }); + + numInputPref.setOnPreferenceChangeListener((preference, newValue) -> { + String val = (String) newValue; + if (TextUtils.isEmpty(val)) { + Toast.makeText(requireContext(), validationError, Toast.LENGTH_SHORT).show(); + return false; + } return true; + }); + } + + @SuppressWarnings("deprecation") // Required to link the dialog to the fragment + @Override + public void onDisplayPreferenceDialog(Preference preference) { + + // Retrieve the default value defined in extras. + // If null, it means this preference doesn't support the reset feature. + // Fallback to the default dialog behavior. + String defaultValue = preference.getExtras().getString(EXTRA_DEFAULT_VALUE); + if (defaultValue == null) { + super.onDisplayPreferenceDialog(preference); + return; } - }); - - // don't allow the logging_min_distance to be empty - final EditText et = ((EditTextPreference)pref).getEditText(); - final EditTextPreference etp = (EditTextPreference)pref; - et.addTextChangedListener( - new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - } - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - if (s.length() >= 0) { - try { - Button bt_ok = ((AlertDialog) etp.getDialog()).getButton(AlertDialog.BUTTON_POSITIVE); - if (s.length() == 0) { - bt_ok.setEnabled(false); - } else { - ((AlertDialog) etp.getDialog()).getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); + // Create the standard dialog fragment + final EditTextPreferenceDialogFragmentCompat dialogFragment = + EditTextPreferenceDialogFragmentCompat.newInstance(preference.getKey()); + dialogFragment.setTargetFragment(this, 0); + dialogFragment.show( + getParentFragmentManager(), + "androidx.preference.PreferenceFragment.DIALOG"); + + // Inject the button after the dialog is shown + getParentFragmentManager().registerFragmentLifecycleCallbacks( + new FragmentManager.FragmentLifecycleCallbacks() { + @Override + public void onFragmentStarted( + @androidx.annotation.NonNull FragmentManager fm, + @androidx.annotation.NonNull androidx.fragment.app.Fragment f) { + if (f == dialogFragment) { + android.app.Dialog dialog = dialogFragment.getDialog(); + if (dialog instanceof androidx.appcompat.app.AlertDialog alertDialog) { + + // Configure the Neutral Button for reset default value + Button btnReset = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL); + btnReset.setText(R.string.prefs_reset_default_value); + btnReset.setVisibility(android.view.View.VISIBLE); + + btnReset.setOnClickListener(v -> { + if (preference instanceof EditTextPreference) { + ((EditTextPreference) preference).setText(defaultValue); + alertDialog.dismiss(); } - } catch (Exception ex) { - } + }); } - } - - @Override - public void afterTextChanged(Editable s) { + // Cleanup + getParentFragmentManager().unregisterFragmentLifecycleCallbacks(this); } } - ); + }, false); + } - pref = findPreference(OSMTracker.Preferences.KEY_GPS_OSSETTINGS); - pref.setOnPreferenceClickListener(new OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)); - return true; - } - }); - - // Button screen orientation option - pref = findPreference(OSMTracker.Preferences.KEY_UI_ORIENTATION); - ListPreference orientationListPreference = (ListPreference) pref; - String displayValueKey = prefs.getString(OSMTracker.Preferences.KEY_UI_ORIENTATION, OSMTracker.Preferences.VAL_UI_ORIENTATION); - int displayValueIndex = orientationListPreference.findIndexOfValue(displayValueKey); - String displayValue = orientationListPreference.getEntries()[displayValueIndex].toString(); - orientationListPreference.setSummary(displayValue + ".\n" - + getResources().getString(R.string.prefs_ui_orientation_summary)); - - // Set a listener to update the preference display after a change is made - pref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Set summary with the display text of the item and a description of the preference - ListPreference orientationListPreference = (ListPreference)preference; - // Pull the display string from the list preference rather than simply using the key value - int newValueIndex = orientationListPreference.findIndexOfValue((String)newValue); - String newPreferenceDisplayValue = orientationListPreference.getEntries()[newValueIndex].toString(); - - preference.setSummary(newPreferenceDisplayValue - + ".\n" + getResources().getString(R.string.prefs_ui_orientation_summary)); - return true; - } - }); - - // Clear OSM data: Disable if there's no OSM data stored - pref = findPreference(OSMTracker.Preferences.KEY_OSM_OAUTH_CLEAR_DATA); - if (prefs.contains(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN)) { - pref.setEnabled(true); - } else { - pref.setEnabled(false); + + /** + * Setup a ListPreference with a custom two lines summary, displays the selected entry + * on the first line, and the static summary on the second line. + * + * @param preferenceKey preference identifier + * @param staticSummary text to show on the second line + */ + private void setupListPreference(String preferenceKey, String staticSummary) { + ListPreference listPref = findPreference(preferenceKey); + + if (listPref == null) return; + + listPref.setSummaryProvider(preference -> { + ListPreference lp = (ListPreference) preference; + CharSequence entry = lp.getEntry(); + + // Handle cases where no value has been selected yet. (backwards compatibility) + String displayValue; + if (entry == null || TextUtils.isEmpty(entry)) { + // Fallback text if no value is set. + displayValue = getString(R.string.prefs_not_set); + } else { + displayValue = entry.toString(); + } + return displayValue + ".\n" + staticSummary; + }); } - pref.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - // Clear data - Editor editor = prefs.edit(); - editor.remove(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN); - editor.commit(); - - preference.setEnabled(false); - return false; - } - }); } - -} +} \ No newline at end of file diff --git a/app/src/main/java/net/osmtracker/activity/TrackDetail.java b/app/src/main/java/net/osmtracker/activity/TrackDetail.java index 5cb640f0c..ef195fd64 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackDetail.java +++ b/app/src/main/java/net/osmtracker/activity/TrackDetail.java @@ -24,10 +24,10 @@ import android.graphics.Paint; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; -import android.util.Log; +import androidx.preference.PreferenceManager; + import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -74,6 +74,8 @@ public class TrackDetail extends TrackDetailEditor implements AdapterView.OnItem /** Does this track have any waypoints? If true, underline Waypoint count in the list. */ private boolean trackHasWaypoints = false; + // Does this track have any notes? If true, underline Notes count in the list. + private boolean trackHasNotes = false; /** * List with track info @@ -129,7 +131,7 @@ protected void onResume() { return; // <--- Early return --- } - // Bind WP count, TP count, start date, etc. + // Bind WP count, TP count, Note count, start date, etc. // Fill name-field only if empty (in case changed by user/restored by onRestoreInstanceState) Track t = Track.build(trackId, cursor, cr, true); @@ -153,6 +155,14 @@ protected void onResume() { map.put(ITEM_VALUE, Integer.toString(t.getTpCount())); data.add(map); + // Notes count + final int notesCount = t.getNoteCount(); + trackHasNotes = (notesCount > 0); + map = new HashMap(); + map.put(ITEM_KEY, getResources().getString(R.string.trackmgr_notes_count)); + map.put(ITEM_VALUE, Integer.toString(t.getNoteCount())); + data.add(map); + // Start date map = new HashMap(); map.put(ITEM_KEY, getResources().getString(R.string.trackdetail_startdate)); @@ -335,22 +345,34 @@ public void onRequestPermissionsResult(int requestCode, } /** - * Handle clicks on list items; for Waypoint count, show this track's list of waypoints ({@link WaypointList}). + * Handle clicks on list items; for Waypoint count and note count, show this track's list of + * waypoints ({@link WaypointList}) or notes ({@link NoteList}). * Ignore all other clicks. * @param position Item number in the list; this method assumes Waypoint count is position 0 (first item). */ public void onItemClick(AdapterView parent, View view, final int position, final long rowid) { - if (position != WP_COUNT_INDEX) { - return; + // Get the Map associated with the clicked row + Map clickedItem; + clickedItem = (Map) parent.getItemAtPosition(position); + + if (clickedItem != null) { + String key = clickedItem.get(ITEM_KEY); + + // You can now logic based on the text if positions are dynamic + if (getString(R.string.trackmgr_waypoints_count).equals(key)) { + Intent i = new Intent(this, WaypointList.class); + i.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + startActivity(i); + } else if (getString(R.string.trackmgr_notes_count).equals(key)) { + Intent i = new Intent(this, NoteList.class); + i.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + startActivity(i); + } } - - Intent i = new Intent(this, WaypointList.class); - i.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, trackId); - startActivity(i); } /** - * Extend SimpleAdapter so we can underline the clickable Waypoint count. + * Extend SimpleAdapter so we can underline the clickable Waypoint and Note count. * Always uses R.layout.trackdetail_item as its list item resource. */ private class TrackDetailSimpleAdapter extends SimpleAdapter @@ -365,21 +387,32 @@ private class TrackDetailSimpleAdapter extends SimpleAdapter * Get the layout for this list item. (trackdetail_item.xml) * @param position Item number in the list */ - public View getView(final int position, View convertView, ViewGroup parent) - { + public View getView(final int position, View convertView, ViewGroup parent) { View v = super.getView(position, convertView, parent); if (! (v instanceof ViewGroup)) return v; // should not happen; v is trackdetail_item, a LinearLayout - final boolean wantsUnderline = ((position == WP_COUNT_INDEX) && trackHasWaypoints); + // Get the data for the current row + Map item = (Map) getItem(position); + String key = item.get(ITEM_KEY); + boolean wantsUnderline = false; + + // Check the key to decide if we should underline + if (getString(R.string.trackmgr_waypoints_count).equals(key) && trackHasWaypoints) { + wantsUnderline = trackHasWaypoints; + } else if (getString(R.string.trackmgr_notes_count).equals(key) && trackHasNotes) { + wantsUnderline = trackHasNotes; + } + + View vi = ((ViewGroup) v).findViewById(R.id.trackdetail_item_key); - if ((vi != null) && (vi instanceof TextView)) - { - final int flags = ((TextView) vi).getPaintFlags(); + if ((vi != null) && (vi instanceof TextView)) { + TextView tv = (TextView) vi; + final int flags = tv.getPaintFlags(); if (wantsUnderline) - ((TextView) vi).setPaintFlags(flags | Paint.UNDERLINE_TEXT_FLAG); + tv.setPaintFlags(flags | Paint.UNDERLINE_TEXT_FLAG); else - ((TextView) vi).setPaintFlags(flags & ~Paint.UNDERLINE_TEXT_FLAG); + tv.setPaintFlags(flags & ~Paint.UNDERLINE_TEXT_FLAG); } return v; } diff --git a/app/src/main/java/net/osmtracker/activity/TrackListRVAdapter.java b/app/src/main/java/net/osmtracker/activity/TrackListRVAdapter.java index 3f11a778b..230b51a16 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackListRVAdapter.java +++ b/app/src/main/java/net/osmtracker/activity/TrackListRVAdapter.java @@ -52,6 +52,7 @@ public class TrackItemVH extends RecyclerView.ViewHolder private final TextView vNameOrStartDate; private final TextView vWps; private final TextView vTps; + private final TextView vNotesCount; private final ImageView vStatus; private final ImageView vUploadStatus; @@ -62,6 +63,7 @@ public TrackItemVH(View view) { vNameOrStartDate = (TextView) view.findViewById(R.id.trackmgr_item_nameordate); vWps = (TextView) view.findViewById(R.id.trackmgr_item_wps); vTps = (TextView) view.findViewById(R.id.trackmgr_item_tps); + vNotesCount = view.findViewById(R.id.trackmgr_item_notes_count); vStatus = (ImageView) view.findViewById(R.id.trackmgr_item_statusicon); vUploadStatus = (ImageView) view.findViewById(R.id.trackmgr_item_upload_statusicon); diff --git a/app/src/main/java/net/osmtracker/activity/TrackLogger.java b/app/src/main/java/net/osmtracker/activity/TrackLogger.java index 0cbb5f405..45c4a1f34 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackLogger.java +++ b/app/src/main/java/net/osmtracker/activity/TrackLogger.java @@ -20,7 +20,6 @@ import android.net.Uri; import android.os.Bundle; import android.os.StrictMode; -import android.preference.PreferenceManager; import android.provider.MediaStore; import android.provider.Settings; import android.util.Log; @@ -36,6 +35,7 @@ import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; +import androidx.preference.PreferenceManager; import net.osmtracker.OSMTracker; import net.osmtracker.R; @@ -313,7 +313,7 @@ protected void onResume() { File layoutFile = new File( this.getExternalFilesDir(null), OSMTracker.Preferences.VAL_STORAGE_DIR - + File.separator + Preferences.LAYOUTS_SUBDIR + + File.separator + DataHelper.LAYOUTS_SUBDIR + File.separator + userLayout); mainLayout = new UserDefinedLayout(this, currentTrackId, layoutFile); } diff --git a/app/src/main/java/net/osmtracker/activity/TrackManager.java b/app/src/main/java/net/osmtracker/activity/TrackManager.java index f4d6ca9e6..4f9f23189 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackManager.java +++ b/app/src/main/java/net/osmtracker/activity/TrackManager.java @@ -1,11 +1,11 @@ package net.osmtracker.activity; -import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -23,7 +23,6 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; import android.util.Base64OutputStream; import android.util.Log; import android.view.ContextMenu; @@ -53,8 +52,6 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.Files; -import java.util.Base64; import java.util.Date; /** @@ -639,6 +636,7 @@ public void onClick(long trackId) { * @throws CreateTrackException */ private long createNewTrack() throws CreateTrackException { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); Date startDate = new Date(); // Create entry in TRACK table @@ -648,6 +646,12 @@ private long createNewTrack() throws CreateTrackException { values.put(TrackContentProvider.Schema.COL_START_DATE, startDate.getTime()); values.put(TrackContentProvider.Schema.COL_ACTIVE, TrackContentProvider.Schema.VAL_TRACK_ACTIVE); + String visibility = preferences.getString( + OSMTracker.Preferences.KEY_OSM_TRACK_VISIBILITY, + OSMTracker.Preferences.VAL_OSM_TRACK_VISIBILITY + ); + Log.d(TAG, "Visibility: " + visibility); + values.put(TrackContentProvider.Schema.COL_OSM_VISIBILITY, visibility); Uri trackUri = getContentResolver().insert(TrackContentProvider.CONTENT_URI_TRACK, values); long trackId = ContentUris.parseId(trackUri); diff --git a/app/src/main/java/net/osmtracker/adapter/NoteAdapter.java b/app/src/main/java/net/osmtracker/adapter/NoteAdapter.java new file mode 100644 index 000000000..896c8dadc --- /dev/null +++ b/app/src/main/java/net/osmtracker/adapter/NoteAdapter.java @@ -0,0 +1,122 @@ +package net.osmtracker.adapter; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import net.osmtracker.R; +import net.osmtracker.db.TrackContentProvider; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +public class NoteAdapter extends RecyclerView.Adapter { + + public static final SimpleDateFormat DATE_FORMATTER = + new SimpleDateFormat("HH:mm:ss 'UTC'", Locale.ROOT); + static { + DATE_FORMATTER.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + private Cursor cursor; + private final OnNoteClickListener listener; + + public interface OnNoteClickListener { + void onNoteClick(long id, long noteId, String uuid, String name); + } + + public NoteAdapter(OnNoteClickListener listener) { + this.listener = listener; + } + + public void swapCursor(Cursor newCursor) { + if (cursor == newCursor) return; + if (cursor != null) cursor.close(); + cursor = newCursor; + notifyDataSetChanged(); + } + + @NonNull + @Override + public NoteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = LayoutInflater.from( + parent.getContext()).inflate(R.layout.notelist_item, parent, false); + return new NoteViewHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull NoteViewHolder holder, int position) { + if (cursor.moveToPosition(position)) { + Context context = holder.itemView.getContext(); + + // Bind name + String name = cursor.getString( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_NAME)); + holder.tvName.setText(name); + + // Upload status + if (cursor.isNull( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE))) { + holder.ivUploadStatus.setVisibility(View.GONE); + } else { + holder.ivUploadStatus.setImageResource(android.R.drawable.stat_sys_upload_done); + holder.ivUploadStatus.setVisibility(View.VISIBLE); + } + + //Bind Location (Latitude/Longitude) + String lat = cursor.getString( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_LATITUDE)); + String lon = cursor.getString( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_LONGITUDE)); + + String locationStr = context.getString(R.string.wplist_latitude) + lat + ", " + + context.getString(R.string.wplist_longitude) + lon; + holder.tvLocation.setText(locationStr); + + // Bind Timestamp + Date ts = new Date(cursor.getLong( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_TIMESTAMP))); + holder.tvTimestamp.setText(DATE_FORMATTER.format(ts)); + + // Setup Click Listener + String uuid = cursor.getString( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_UUID)); + long trackId = cursor.getLong( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_TRACK_ID)); + long noteId = cursor.getLong( + cursor.getColumnIndexOrThrow(TrackContentProvider.Schema.COL_ID)); + holder.itemView.setOnClickListener( + v -> listener.onNoteClick(trackId, noteId, uuid, name)); + } + } + + @Override + public int getItemCount() { + return (cursor == null) ? 0 : cursor.getCount(); + } + + public static class NoteViewHolder extends RecyclerView.ViewHolder { + TextView tvName; + ImageView ivUploadStatus; + TextView tvLocation; + TextView tvTimestamp; + + + public NoteViewHolder(View v) { + super(v); + tvName = v.findViewById(R.id.notelist_item_name); + ivUploadStatus = v.findViewById(R.id.notelist_item_upload_status_icon); + tvLocation = v.findViewById(R.id.notelist_item_location); + tvTimestamp = v.findViewById(R.id.notelist_item_timestamp); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/osmtracker/db/DataHelper.java b/app/src/main/java/net/osmtracker/db/DataHelper.java index aafdbb751..37935bec1 100644 --- a/app/src/main/java/net/osmtracker/db/DataHelper.java +++ b/app/src/main/java/net/osmtracker/db/DataHelper.java @@ -8,10 +8,10 @@ import android.database.Cursor; import android.location.Location; import android.net.Uri; -import android.os.Environment; -import android.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.db.model.Track; import net.osmtracker.db.model.TrackPoint; @@ -53,6 +53,12 @@ public class DataHelper { */ public static final String EXTENSION_ZIP = ".zip"; + /** + * File extension for layout files + */ + + public static final String LAYOUT_FILE_EXTENSION = ".xml"; + /** * GPX Files MIME standard for sharing */ @@ -68,6 +74,19 @@ public class DataHelper { */ public static final String MIME_TYPE_IMAGE = "image/*"; + /** + * Directory containing user layouts, relative to storage dir. + */ + public static final String LAYOUTS_SUBDIR = "layouts"; + + /** + * The suffix that must be added to the layout's name for getting its icons directory + * Example: water_supply <- layout name + * water_supply_icons <- icon directory + */ + + public static final String LAYOUTS_ICONS_DIR_SUFFIX = "_icons"; + /** * APP sign plus FileProvider = authority */ @@ -288,7 +307,76 @@ public void deleteWayPoint(String uuid, String filepath) { Log.v(TAG, "File deleted: " + filepath); } } - + + /** + * Tracks a note point with link + * + * @param trackId Id of the track + * @param location Location of note + * @param name text of the note + * @param uuid Unique id of the note + */ + public void trackNote(long trackId, Location location, String name, String uuid) { + Log.d(TAG, "Tracking note '" + name + "', track=" + trackId + ", uuid=" + uuid + + ", nbSatellites=" + location.getExtras().getInt("satellites") + + ", location=" + location); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_TRACK_ID, trackId); + values.put(TrackContentProvider.Schema.COL_LATITUDE, location.getLatitude()); + values.put(TrackContentProvider.Schema.COL_LONGITUDE, location.getLongitude()); + values.put(TrackContentProvider.Schema.COL_NAME, name); + + if (uuid != null) { + values.put(TrackContentProvider.Schema.COL_UUID, uuid); + } + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.getBoolean(OSMTracker.Preferences.KEY_GPS_IGNORE_CLOCK, OSMTracker.Preferences.VAL_GPS_IGNORE_CLOCK)) { + // Use OS clock + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, System.currentTimeMillis()); + } else { + // Use GPS clock + values.put(TrackContentProvider.Schema.COL_TIMESTAMP, location.getTime()); + } + + Uri trackUri = ContentUris.withAppendedId(TrackContentProvider.CONTENT_URI_TRACK, trackId); + contentResolver.insert(Uri.withAppendedPath(trackUri, + TrackContentProvider.Schema.TBL_NOTE + "s"), values); + } + + /** + * Updates a note + * + * @param trackId Id of the track + * @param uuid Unique ID of the target note + * @param name New text value for the note + */ + public void updateNote(long trackId, String uuid, String name) { + Log.v(TAG, "Updating note with uuid '" + uuid + "'. New values: name='" + name); + if (uuid != null) { + ContentValues values = new ContentValues(); + if (name != null) { + values.put(TrackContentProvider.Schema.COL_NAME, name); + } + + Uri trackUri = ContentUris.withAppendedId(TrackContentProvider.CONTENT_URI_TRACK, trackId); + contentResolver.update(Uri.withAppendedPath(trackUri, TrackContentProvider.Schema.TBL_NOTE + "s"), values, + "uuid = ?", new String[] { uuid }); + } + } + + /** + * Deletes a note + * + * @param uuid Unique ID of the target waypoint + */ + public void deleteNote(String uuid) { + Log.v(TAG, "Deleting note with uuid '" + uuid); + if (uuid != null) { + contentResolver.delete(Uri.withAppendedPath(TrackContentProvider.CONTENT_URI_NOTE_UUID, uuid), null, null); + } + } /** * Stop tracking by making the track inactive @@ -349,6 +437,12 @@ public static void setTrackUploadDate(long trackId, long uploadTime, ContentReso cr.update(trackUri, values, null, null); } + public static void setNoteUploadDate(long noteId, long uploadTime, ContentResolver cr) { + Uri noteUri = ContentUris.withAppendedId(TrackContentProvider.CONTENT_URI_NOTE, noteId); + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE, uploadTime); + cr.update(noteUri, values, null, null); + } /** * Renames a file inside track directory, keeping the extension * diff --git a/app/src/main/java/net/osmtracker/db/DatabaseHelper.java b/app/src/main/java/net/osmtracker/db/DatabaseHelper.java index 45775608c..fda2032c5 100644 --- a/app/src/main/java/net/osmtracker/db/DatabaseHelper.java +++ b/app/src/main/java/net/osmtracker/db/DatabaseHelper.java @@ -98,6 +98,22 @@ public class DatabaseHelper extends SQLiteOpenHelper { + TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE + " long" // null indicates not yet uploaded + ")"; + /** + * SQL for creating table NOTE + * @since 18 (version 2026.02) + */ + private static final String SQL_CREATE_TABLE_NOTE = "" + + "create table " + TrackContentProvider.Schema.TBL_NOTE + " (" + + TrackContentProvider.Schema.COL_ID + " integer primary key autoincrement," + + TrackContentProvider.Schema.COL_TRACK_ID + " integer not null," + + TrackContentProvider.Schema.COL_UUID + " text," + + TrackContentProvider.Schema.COL_LATITUDE + " double not null," + + TrackContentProvider.Schema.COL_LONGITUDE + " double not null," + + TrackContentProvider.Schema.COL_TIMESTAMP + " long not null," + + TrackContentProvider.Schema.COL_NAME + " text," + + TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE + " long" // null indicates not yet uploaded + + ")"; + /** * Database name. */ @@ -123,9 +139,10 @@ public class DatabaseHelper extends SQLiteOpenHelper { * v16: add TBL_TRACKPOINT.COL_COMPASS, TBL_TRACKPOINT.COL_COMPASS_ACCURACY, * TBL_WAYPOINT.COL_COMPASS and TBL_WAYPOINT.COL_COMPASS_ACCURACY * v17: add TBL_TRACKPOINT.COL_ATMOSPHERIC_PRESSURE and TBL_WAYPOINT.COL_ATMOSPHERIC_PRESSURE + * v18: add TBL_NOTE * */ - private static final int DB_VERSION = 17; + private static final int DB_VERSION = 18; private Context context; @@ -144,6 +161,8 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_IDX_WAYPOINT_TRACK); db.execSQL("drop table if exists " + TrackContentProvider.Schema.TBL_TRACK); db.execSQL(SQL_CREATE_TABLE_TRACK); + db.execSQL("drop table if exists " + TrackContentProvider.Schema.TBL_NOTE); + db.execSQL(SQL_CREATE_TABLE_NOTE); } @Override @@ -181,6 +200,8 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { case 16: db.execSQL("alter table " + TrackContentProvider.Schema.TBL_TRACKPOINT + " add column " + TrackContentProvider.Schema.COL_ATMOSPHERIC_PRESSURE + " double null"); db.execSQL("alter table " + TrackContentProvider.Schema.TBL_WAYPOINT + " add column " + TrackContentProvider.Schema.COL_ATMOSPHERIC_PRESSURE + " double null"); + case 17: + db.execSQL(SQL_CREATE_TABLE_NOTE); } } diff --git a/app/src/main/java/net/osmtracker/db/TrackContentProvider.java b/app/src/main/java/net/osmtracker/db/TrackContentProvider.java index 38f344221..a5129c8a4 100644 --- a/app/src/main/java/net/osmtracker/db/TrackContentProvider.java +++ b/app/src/main/java/net/osmtracker/db/TrackContentProvider.java @@ -36,6 +36,8 @@ public class TrackContentProvider extends ContentProvider { */ public static final Uri CONTENT_URI_TRACK = Uri.parse("content://" + AUTHORITY + "/" + Schema.TBL_TRACK); + public static final Uri CONTENT_URI_NOTE = Uri.parse("content://" + AUTHORITY + "/" + Schema.TBL_NOTE); + /** * Uri for the active track */ @@ -51,6 +53,11 @@ public class TrackContentProvider extends ContentProvider { */ public static final Uri CONTENT_URI_WAYPOINT_UUID = Uri.parse("content://" + AUTHORITY + "/" + Schema.TBL_WAYPOINT + "/uuid"); + /** + * Uri for a specific note by uuid + */ + public static final Uri CONTENT_URI_NOTE_UUID = Uri.parse("content://" + AUTHORITY + "/" + Schema.TBL_NOTE + "/uuid"); + /** * Uri for a specific trackpoint */ @@ -76,7 +83,16 @@ public class TrackContentProvider extends ContentProvider { Schema.COL_OSM_VISIBILITY, Schema.COL_START_DATE, "count(" + Schema.TBL_TRACKPOINT + "." + Schema.COL_ID + ") as " + Schema.COL_TRACKPOINT_COUNT, - "(SELECT count("+Schema.TBL_WAYPOINT+"."+Schema.COL_TRACK_ID+") FROM "+Schema.TBL_WAYPOINT+" WHERE "+Schema.TBL_WAYPOINT+"."+Schema.COL_TRACK_ID+" = " + Schema.TBL_TRACK + "." + Schema.COL_ID + ") as " + Schema.COL_WAYPOINT_COUNT + "(SELECT count(" + Schema.TBL_WAYPOINT + "." + Schema.COL_TRACK_ID +") " + + "FROM " + Schema.TBL_WAYPOINT + " " + + "WHERE " + Schema.TBL_WAYPOINT + "." + Schema.COL_TRACK_ID +" " + + "= " + Schema.TBL_TRACK + "." + Schema.COL_ID +") " + + "as " + Schema.COL_WAYPOINT_COUNT, + "(SELECT count(" + Schema.TBL_NOTE + "." + Schema.COL_TRACK_ID +") " + + "FROM " + Schema.TBL_NOTE + " " + + "WHERE " + Schema.TBL_NOTE + "." + Schema.COL_TRACK_ID +" " + + "= " + Schema.TBL_TRACK + "." + Schema.COL_ID +") " + + "as " + Schema.COL_NOTE_COUNT, }; /** @@ -97,11 +113,13 @@ public class TrackContentProvider extends ContentProvider { uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACK + "/#/start", Schema.URI_CODE_TRACK_START); uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACK + "/#/end", Schema.URI_CODE_TRACK_END); uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACK + "/#/" + Schema.TBL_WAYPOINT + "s", Schema.URI_CODE_TRACK_WAYPOINTS); + uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACK + "/#/" + Schema.TBL_NOTE + "s", Schema.URI_CODE_TRACK_NOTES); uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACK + "/#/" + Schema.TBL_TRACKPOINT + "s", Schema.URI_CODE_TRACK_TRACKPOINTS); uriMatcher.addURI(AUTHORITY, Schema.TBL_WAYPOINT + "/#", Schema.URI_CODE_WAYPOINT_ID); uriMatcher.addURI(AUTHORITY, Schema.TBL_WAYPOINT + "/uuid/*", Schema.URI_CODE_WAYPOINT_UUID); uriMatcher.addURI(AUTHORITY, Schema.TBL_TRACKPOINT + "/#", Schema.URI_CODE_TRACKPOINT_ID); - + uriMatcher.addURI(AUTHORITY, Schema.TBL_NOTE + "/#", Schema.URI_CODE_NOTE_ID); + uriMatcher.addURI(AUTHORITY, Schema.TBL_NOTE + "/uuid/*", Schema.URI_CODE_NOTE_UUID); } /** @@ -121,7 +139,25 @@ public static final Uri waypointsUri(long trackId) { public static final Uri waypointUri(long waypointId) { return ContentUris.withAppendedId(CONTENT_URI_WAYPOINT, waypointId); } - + + /** + * @param noteId target note id + * @return Uri for the note + */ + public static final Uri noteUri(long noteId) { + return ContentUris.withAppendedId(CONTENT_URI_NOTE, noteId); + } + + /** + * @param trackId target track id + * @return Uri for the notes of the track + */ + public static final Uri notesUri(long trackId) { + return Uri.withAppendedPath( + ContentUris.withAppendedId(CONTENT_URI_TRACK, trackId), + Schema.TBL_NOTE + "s" ); + } + /** * @param trackId target track id * @return Uri for the trackpoints of the track @@ -196,6 +232,14 @@ public int delete(Uri uri, String selection, String[] selectionArgs) { count = 0; } break; + case Schema.URI_CODE_NOTE_UUID: + String noteUUID = uri.getLastPathSegment(); + if(noteUUID != null){ + count = dbHelper.getWritableDatabase().delete(Schema.TBL_NOTE, Schema.COL_UUID + " = ?", new String[]{noteUUID}); + }else{ + count = 0; + } + break; default: throw new IllegalArgumentException("Unknown URI: " + uri); } @@ -207,7 +251,7 @@ public int delete(Uri uri, String selection, String[] selectionArgs) { /** * Match and get the URI type, if recognized: * Matches {@link Schema#URI_CODE_TRACK_TRACKPOINTS}, {@link Schema#URI_CODE_TRACK_WAYPOINTS}, - * or {@link Schema#URI_CODE_TRACK}. + * {link Schema#URI_CODE_TRACK_NOTES} or {@link Schema#URI_CODE_TRACK}. * @throws IllegalArgumentException if not matched */ @Override @@ -222,6 +266,9 @@ public String getType(Uri uri) throws IllegalArgumentException { case Schema.URI_CODE_TRACK_WAYPOINTS: return ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd." + OSMTracker.class.getPackage() + "." + Schema.TBL_WAYPOINT; + case Schema.URI_CODE_TRACK_NOTES: + return ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd." + OSMTracker.class.getPackage() + "." + + Schema.TBL_NOTE; case Schema.URI_CODE_TRACK: return ContentResolver.CURSOR_DIR_BASE_TYPE + "/vnd." + OSMTracker.class.getPackage() + "." + Schema.TBL_TRACK; @@ -268,6 +315,19 @@ public Uri insert(Uri uri, ContentValues values) { + Schema.COL_LATITUDE + ", " + Schema.COL_TIMESTAMP); } break; + case Schema.URI_CODE_TRACK_NOTES: + // Check that mandatory columns are present. + if (values.containsKey(Schema.COL_TRACK_ID) && values.containsKey(Schema.COL_LONGITUDE) + && values.containsKey(Schema.COL_LATITUDE) && values.containsKey(Schema.COL_TIMESTAMP) ) { + + long rowId = dbHelper.getWritableDatabase().insert(Schema.TBL_NOTE, null, values); + if (rowId > 0) { + Uri noteUri = ContentUris.withAppendedId(uri, rowId); + getContext().getContentResolver().notifyChange(noteUri, null); + return noteUri; + } + } + break; case Schema.URI_CODE_TRACK: if (values.containsKey(Schema.COL_START_DATE)) { long rowId = dbHelper.getWritableDatabase().insert(Schema.TBL_TRACK, null, values); @@ -333,6 +393,26 @@ public Cursor query(Uri uri, String[] projection, String selectionIn, String[] s selection = Schema.COL_TRACK_ID + " = ?"; selectionArgs = new String[] {trackId}; break; + case Schema.URI_CODE_TRACK_NOTES: + if (selectionIn != null || selectionArgsIn != null) { + // Any selection/selectionArgs will be ignored + throw new UnsupportedOperationException(); + } + trackId = uri.getPathSegments().get(1); + qb.setTables(Schema.TBL_NOTE); + selection = Schema.COL_TRACK_ID + " = ?"; + selectionArgs = new String[] {trackId}; + break; + case Schema.URI_CODE_NOTE_ID: + if (selectionIn != null || selectionArgsIn != null) { + // Any selection/selectionArgs will be ignored + throw new UnsupportedOperationException(); + } + String noteId = uri.getPathSegments().get(1); + qb.setTables(Schema.TBL_NOTE); + selection = Schema.COL_ID + " = ?"; + selectionArgs = new String[] {noteId}; + break; case Schema.URI_CODE_TRACK_START: if (selectionIn != null || selectionArgsIn != null) { // Any selection/selectionArgs will be ignored @@ -430,6 +510,23 @@ public int update(Uri uri, ContentValues values, String selectionIn, String[] se } table = Schema.TBL_WAYPOINT; break; + case Schema.URI_CODE_TRACK_NOTES: + if (selectionIn == null || selectionArgsIn == null) { + // Caller must narrow to a specific waypoint + throw new IllegalArgumentException(); + } + table = Schema.TBL_NOTE; + break; + case Schema.URI_CODE_NOTE_ID: + if (selectionIn != null || selectionArgsIn != null) { + // Any selection/selectionArgs will be ignored + throw new UnsupportedOperationException(); + } + table = Schema.TBL_NOTE; + String noteId = uri.getLastPathSegment(); + selection = Schema.COL_ID + " = ?"; + selectionArgs = new String[] {noteId}; + break; case Schema.URI_CODE_TRACK_ID: if (selectionIn != null || selectionArgsIn != null) { // Any selection/selectionArgs will be ignored @@ -470,6 +567,7 @@ public int update(Uri uri, ContentValues values, String selectionIn, String[] se public static final class Schema { public static final String TBL_TRACKPOINT = "trackpoint"; public static final String TBL_WAYPOINT = "waypoint"; + public static final String TBL_NOTE = "note"; public static final String TBL_TRACK = "track"; public static final String COL_ID = "_id"; public static final String COL_TRACK_ID = "track_id"; @@ -499,7 +597,8 @@ public static final class Schema { // virtual colums that are used in some sqls but dont exist in database public static final String COL_TRACKPOINT_COUNT = "tp_count"; public static final String COL_WAYPOINT_COUNT = "wp_count"; - + public static final String COL_NOTE_COUNT = "note_count"; + // Codes for UriMatcher public static final int URI_CODE_TRACK = 3; public static final int URI_CODE_TRACK_ID = 4; @@ -511,6 +610,9 @@ public static final class Schema { public static final int URI_CODE_TRACK_END = 10; public static final int URI_CODE_WAYPOINT_ID = 11; public static final int URI_CODE_TRACKPOINT_ID = 12; + public static final int URI_CODE_TRACK_NOTES = 13; + public static final int URI_CODE_NOTE_ID = 14; + public static final int URI_CODE_NOTE_UUID = 15; public static final int VAL_TRACK_ACTIVE = 1; diff --git a/app/src/main/java/net/osmtracker/db/TracklistAdapter.java b/app/src/main/java/net/osmtracker/db/TracklistAdapter.java index d99c4ced8..fe45edae6 100644 --- a/app/src/main/java/net/osmtracker/db/TracklistAdapter.java +++ b/app/src/main/java/net/osmtracker/db/TracklistAdapter.java @@ -54,6 +54,7 @@ private View bind(Cursor cursor, View v, Context context) { TextView vNameOrStartDate = (TextView) v.findViewById(R.id.trackmgr_item_nameordate); TextView vWps = (TextView) v.findViewById(R.id.trackmgr_item_wps); TextView vTps = (TextView) v.findViewById(R.id.trackmgr_item_tps); + TextView vNotesCount = v.findViewById(R.id.trackmgr_item_notes_count); ImageView vStatus = (ImageView) v.findViewById(R.id.trackmgr_item_statusicon); ImageView vUploadStatus = (ImageView) v.findViewById(R.id.trackmgr_item_upload_statusicon); @@ -86,10 +87,11 @@ private View bind(Cursor cursor, View v, Context context) { String strTrackId = Long.toString(trackId); vId.setText(strTrackId); - // Bind WP count, TP count, name + // Bind WP count, TP count, Notes count, name Track t = Track.build(trackId, cursor, context.getContentResolver(), false); vTps.setText(Integer.toString(t.getTpCount())); vWps.setText(Integer.toString(t.getWpCount())); + vNotesCount.setText(Integer.toString(t.getNoteCount())); vNameOrStartDate.setText(t.getDisplayName()); return v; diff --git a/app/src/main/java/net/osmtracker/db/model/Track.java b/app/src/main/java/net/osmtracker/db/model/Track.java index b74fcfe3d..03261b482 100644 --- a/app/src/main/java/net/osmtracker/db/model/Track.java +++ b/app/src/main/java/net/osmtracker/db/model/Track.java @@ -51,7 +51,7 @@ public static OSMVisibility fromPosition(int position) { private String description; private OSMVisibility visibility; private List tags = new ArrayList(); - private int tpCount, wpCount; + private int tpCount, wpCount, noteCount; private long trackDate; private long trackId; @@ -92,7 +92,9 @@ public static Track build(final long trackId, Cursor tc, ContentResolver cr, boo out.tpCount = tc.getInt(tc.getColumnIndex(TrackContentProvider.Schema.COL_TRACKPOINT_COUNT)); out.wpCount = tc.getInt(tc.getColumnIndex(TrackContentProvider.Schema.COL_WAYPOINT_COUNT)); - + + out.noteCount = tc.getInt(tc.getColumnIndex(TrackContentProvider.Schema.COL_NOTE_COUNT)); + if(withExtraInformation){ out.readExtraInformation(); } @@ -145,6 +147,10 @@ public void setWpCount(int wpCount) { this.wpCount = wpCount; } + public void setNoteCount(int noteCount) { + this.noteCount = noteCount; + } + public void setTracktDate(long tracktDate) { this.trackDate = tracktDate; } @@ -191,6 +197,10 @@ public Integer getTpCount() { return tpCount; } + public Integer getNoteCount() { + return noteCount; + } + // @deprecated public String getDisplayName() { if (name != null && name.length() > 0) { diff --git a/app/src/main/java/net/osmtracker/gpx/ExportToStorageTask.java b/app/src/main/java/net/osmtracker/gpx/ExportToStorageTask.java index 855929f85..347bf3467 100644 --- a/app/src/main/java/net/osmtracker/gpx/ExportToStorageTask.java +++ b/app/src/main/java/net/osmtracker/gpx/ExportToStorageTask.java @@ -5,9 +5,10 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.Environment; -import android.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.R; import net.osmtracker.db.DataHelper; diff --git a/app/src/main/java/net/osmtracker/gpx/ExportToTempFileTask.java b/app/src/main/java/net/osmtracker/gpx/ExportToTempFileTask.java index fdc971180..61369fca4 100644 --- a/app/src/main/java/net/osmtracker/gpx/ExportToTempFileTask.java +++ b/app/src/main/java/net/osmtracker/gpx/ExportToTempFileTask.java @@ -2,9 +2,10 @@ import android.content.Context; import android.database.Cursor; -import android.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.db.DataHelper; import net.osmtracker.exception.ExportTrackException; diff --git a/app/src/main/java/net/osmtracker/gpx/ExportTrackTask.java b/app/src/main/java/net/osmtracker/gpx/ExportTrackTask.java index a9dec64da..c54faa2a3 100644 --- a/app/src/main/java/net/osmtracker/gpx/ExportTrackTask.java +++ b/app/src/main/java/net/osmtracker/gpx/ExportTrackTask.java @@ -10,7 +10,6 @@ import android.database.Cursor; import android.media.MediaScannerConnection; import android.os.AsyncTask; -import android.preference.PreferenceManager; import android.util.Log; import android.widget.Toast; @@ -36,6 +35,8 @@ import static net.osmtracker.db.DataHelper.EXTENSION_GPX; import static net.osmtracker.util.FileSystemUtils.getUniqueChildNameFor; +import androidx.preference.PreferenceManager; + /** * Base class to writes a GPX file and export * track media (Photos, Sounds) diff --git a/app/src/main/java/net/osmtracker/layout/DownloadCustomLayoutTask.java b/app/src/main/java/net/osmtracker/layout/DownloadCustomLayoutTask.java index 2c4048aa5..525c34447 100644 --- a/app/src/main/java/net/osmtracker/layout/DownloadCustomLayoutTask.java +++ b/app/src/main/java/net/osmtracker/layout/DownloadCustomLayoutTask.java @@ -4,9 +4,10 @@ import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.Environment; -import android.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + import org.json.JSONArray; import org.json.JSONObject; import net.osmtracker.OSMTracker; @@ -21,7 +22,7 @@ import java.util.Set; import net.osmtracker.R; -import net.osmtracker.activity.Preferences; +import net.osmtracker.db.DataHelper; import net.osmtracker.util.CustomLayoutsUtils; import net.osmtracker.util.URLCreator; @@ -55,11 +56,11 @@ public boolean downloadLayout(String layoutName, String iso){ String layoutURL = URLCreator.createLayoutFileURL(context, layoutFolderName, iso); String layoutPath = context.getExternalFilesDir(null) + storageDir + File.separator + - Preferences.LAYOUTS_SUBDIR + File.separator; + DataHelper.LAYOUTS_SUBDIR + File.separator; //TODO: change "_icons" for Preferences.ICONS_DIR_SUFFIX String iconsPath = context.getExternalFilesDir(null) + storageDir + File.separator + - Preferences.LAYOUTS_SUBDIR + File.separator + layoutFolderName+"_icons" + + DataHelper.LAYOUTS_SUBDIR + File.separator + layoutFolderName+"_icons" + File.separator; Boolean status = false; diff --git a/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java b/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java index f2b264d9d..3afd60bc5 100644 --- a/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java +++ b/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java @@ -16,10 +16,11 @@ import android.location.LocationManager; import android.location.LocationProvider; import android.os.Bundle; -import android.preference.PreferenceManager; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; + import android.util.AttributeSet; import android.util.Log; import android.view.LayoutInflater; diff --git a/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java b/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java index f51469320..a7610be46 100644 --- a/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java +++ b/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java @@ -19,7 +19,7 @@ public static class OAuth2 { public static final String CLIENT_ID = (DEV_MODE) ? CLIENT_ID_DEV : CLIENT_ID_PROD; - public static final String SCOPE = "write_gpx"; + public static final String SCOPE = "write_gpx write_notes"; public static final String USER_AGENT = "OSMTracker for Android™"; public static class Urls { diff --git a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java new file mode 100644 index 000000000..28c6c560b --- /dev/null +++ b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java @@ -0,0 +1,173 @@ +package net.osmtracker.osm; + +import android.app.Activity; +import android.util.Log; +import android.view.Gravity; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.preference.PreferenceManager; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; +import net.osmtracker.db.DataHelper; +import net.osmtracker.util.DialogUtils; + +import java.lang.ref.WeakReference; + +import de.westnordost.osmapi.OsmConnection; +import de.westnordost.osmapi.common.errors.OsmAuthorizationException; +import de.westnordost.osmapi.common.errors.OsmBadUserInputException; +import de.westnordost.osmapi.map.data.OsmLatLon; +import de.westnordost.osmapi.notes.NotesApi; + +/** + * Uploads a note to OpenStreetMap + * + * @author Most of the code was made by Nicolas Guillaumin, adapted by Jose Andrés Vargas Serrano + */ +public class UploadToOpenStreetMapNotesTask { + + private static final String TAG = UploadToOpenStreetMapNotesTask.class.getSimpleName(); + + // Result constants + private static final int RESULT_OK = 1; + private static final int RESULT_ERROR_INTERNAL = -1; + private static final int RESULT_ERROR_AUTH = -2; + private static final int RESULT_ERROR_OSM_USER = -3; + + private final WeakReference activityRef; + private final String accessToken; + + //OSM Note data + private final long noteId; + private final String noteText; + private final double longitude; + private final double latitude; + + private AlertDialog progressDialog; + // Error message returned by OSM if the request completed + private String errorMsg; + private int resultCode = RESULT_ERROR_INTERNAL; + + public UploadToOpenStreetMapNotesTask(Activity activity, String accessToken, long noteId, + String noteText, double latitude, double longitude) { + this.activityRef = new WeakReference<>(activity); + this.accessToken = accessToken; + this.noteId = noteId; + this.noteText = noteText; + this.longitude = longitude; + this.latitude = latitude; + } + + /** + * Synchronous execution for use with ExecutorService. + * Replaces the lifecycle of AsyncTask. */ + public void run() { + final Activity activity = activityRef.get(); + if (activity == null || activity.isFinishing()) return; + + // 1. Prepare UI (Equivalent to onPreExecute) + activity.runOnUiThread(() -> progressDialog = createProgressDialog(activity)); + + // 2. Execute Network Logic (Equivalent to doInBackground) + OsmConnection osm = new OsmConnection( + OpenStreetMapConstants.Api.OSM_API_URL_PATH, + OpenStreetMapConstants.OAuth2.USER_AGENT, + accessToken); + + try { + new NotesApi(osm).create(new OsmLatLon(latitude, longitude), noteText); + resultCode = RESULT_OK; + } catch (OsmBadUserInputException e) { + Log.e(TAG, "Bad OSM user input or illegal argument", e); + errorMsg = e.getLocalizedMessage(); + resultCode = RESULT_ERROR_OSM_USER; + } catch (OsmAuthorizationException oae) { + Log.e(TAG, "OSM Authorization error", oae); + errorMsg = oae.getLocalizedMessage(); + resultCode = RESULT_ERROR_AUTH; + } catch (Exception e) { + Log.e(TAG, "Upload error", e); + errorMsg = e.getLocalizedMessage(); + resultCode = RESULT_ERROR_INTERNAL; + } + + // 3. Handle Results (Equivalent to onPostExecute) + activity.runOnUiThread(() -> { + Activity act = activityRef.get(); + if (act == null || act.isFinishing()) return; + + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + } + + handleResult(act); + }); + } + + private void handleResult(Activity activity) { + switch (resultCode) { + case RESULT_OK: + DataHelper.setNoteUploadDate(noteId, + System.currentTimeMillis(), activity.getContentResolver()); + new AlertDialog.Builder(activity) + .setIcon(android.R.drawable.ic_dialog_info) + .setMessage(R.string.osm_upload_sucess) + .setPositiveButton(android.R.string.ok, + (d, w) -> activity.finish()) + .show(); + break; + + case RESULT_ERROR_AUTH: + showAuthErrorDialog(activity); + break; + + default: + DialogUtils.showErrorDialog(activity, + activity.getString(R.string.osm_note_upload_error) + ": " + errorMsg); + break; + } + } + + private AlertDialog createProgressDialog(Activity activity) { + LinearLayout layout = new LinearLayout(activity); + layout.setOrientation(LinearLayout.HORIZONTAL); + layout.setPadding(50, 50, 50, 50); + layout.setGravity(Gravity.CENTER_VERTICAL); + + ProgressBar pb = new ProgressBar(activity); + pb.setIndeterminate(true); + layout.addView(pb); + + TextView tv = new TextView(activity); + tv.setText(R.string.osm_note_upload); + tv.setPadding(40, 0, 0, 0); + layout.addView(tv); + + AlertDialog progressDialog = new AlertDialog.Builder(activity) + .setView(layout).setCancelable(false).create(); + progressDialog.show(); + return progressDialog; + } + + /** + * Helper to show the specific authorization error dialog + */ + private void showAuthErrorDialog(Activity activity) { + new AlertDialog.Builder(activity) + .setTitle(android.R.string.dialog_alert_title) + .setIcon(android.R.drawable.ic_dialog_alert) + .setMessage(R.string.osm_note_upload_unauthorized) + .setCancelable(true) + .setNegativeButton(android.R.string.no, (d, w) -> d.dismiss()) + .setPositiveButton(android.R.string.yes, (d, w) -> { + PreferenceManager.getDefaultSharedPreferences(activity).edit() + .remove(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN).apply(); + d.dismiss(); + }).show(); + } + +} diff --git a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java index f78c80b9f..e4bb3c669 100644 --- a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java +++ b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java @@ -6,9 +6,10 @@ import android.content.DialogInterface; import android.content.SharedPreferences.Editor; import android.os.AsyncTask; -import android.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.R; import net.osmtracker.db.DataHelper; @@ -123,7 +124,6 @@ protected void onPostExecute(Void result) { DataHelper.setTrackUploadDate(trackId, System.currentTimeMillis(), activity.getContentResolver()); new AlertDialog.Builder(activity) - .setTitle(android.R.string.dialog_alert_title) .setIcon(android.R.drawable.ic_dialog_info) .setMessage(R.string.osm_upload_sucess) .setCancelable(true) diff --git a/app/src/main/java/net/osmtracker/service/gps/GPSLogger.java b/app/src/main/java/net/osmtracker/service/gps/GPSLogger.java index fb785d279..7f180541a 100644 --- a/app/src/main/java/net/osmtracker/service/gps/GPSLogger.java +++ b/app/src/main/java/net/osmtracker/service/gps/GPSLogger.java @@ -18,11 +18,11 @@ import android.os.Build; import android.os.Bundle; import android.os.IBinder; -import android.preference.PreferenceManager; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; import net.osmtracker.OSMTracker; import net.osmtracker.R; @@ -105,7 +105,7 @@ public class GPSLogger extends Service implements LocationListener { private PressureListener pressureListener = new PressureListener(); /** - * Receives Intent for way point tracking, and stop/start logging. + * Receives Intent for way point and notes tracking, and stop/start logging. */ private BroadcastReceiver receiver = new BroadcastReceiver() { @@ -158,6 +158,33 @@ public void onReceive(Context context, Intent intent) { catch(NullPointerException ne){} dataHelper.deleteWayPoint(uuid, filePath); } + } else if (OSMTracker.INTENT_TRACK_NOTE.equals(intent.getAction())) { + // Track a note + Bundle extras = intent.getExtras(); + if (extras != null) { + // because of the gps logging interval our last fix could be very old + // so we'll request the last known location from the gps provider + if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + lastLocation = lmgr.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (lastLocation != null) { + //TODO: CHECK THIS + long trackId = extras.getLong(TrackContentProvider.Schema.COL_TRACK_ID); + String uuid = extras.getString(OSMTracker.INTENT_KEY_UUID); + String name = extras.getString(OSMTracker.INTENT_KEY_NAME); + + dataHelper.trackNote(trackId, lastLocation, name, uuid); + } + } + } + } else if (OSMTracker.INTENT_UPDATE_NOTE.equals(intent.getAction())) { + // Update an existing note + Bundle extras = intent.getExtras(); + if (extras != null) { + long trackId = extras.getLong(TrackContentProvider.Schema.COL_TRACK_ID); + String uuid = extras.getString(OSMTracker.INTENT_KEY_UUID); + String name = extras.getString(OSMTracker.INTENT_KEY_NAME); + dataHelper.updateNote(trackId, uuid, name); + } } else if (OSMTracker.INTENT_START_TRACKING.equals(intent.getAction())) { Bundle extras = intent.getExtras(); if (extras != null) { @@ -227,6 +254,8 @@ public void onCreate() { IntentFilter filter = new IntentFilter(); filter.addAction(OSMTracker.INTENT_TRACK_WP); filter.addAction(OSMTracker.INTENT_UPDATE_WP); + filter.addAction(OSMTracker.INTENT_TRACK_NOTE); + filter.addAction(OSMTracker.INTENT_UPDATE_NOTE); filter.addAction(OSMTracker.INTENT_DELETE_WP); filter.addAction(OSMTracker.INTENT_START_TRACKING); filter.addAction(OSMTracker.INTENT_STOP_TRACKING); diff --git a/app/src/main/java/net/osmtracker/util/CustomLayoutsUtils.java b/app/src/main/java/net/osmtracker/util/CustomLayoutsUtils.java index b07f7884f..6e27b27ce 100644 --- a/app/src/main/java/net/osmtracker/util/CustomLayoutsUtils.java +++ b/app/src/main/java/net/osmtracker/util/CustomLayoutsUtils.java @@ -2,11 +2,12 @@ import android.content.Context; import android.content.SharedPreferences; -import android.preference.PreferenceManager; + +import androidx.preference.PreferenceManager; import net.osmtracker.OSMTracker; import net.osmtracker.activity.AvailableLayouts; -import net.osmtracker.activity.Preferences; +import net.osmtracker.db.DataHelper; import net.osmtracker.layout.GetStringResponseTask; import java.io.BufferedReader; @@ -32,7 +33,7 @@ public class CustomLayoutsUtils { */ public static String convertFileName(String fileName) { //Remove de file extension - String subname = fileName.replace(Preferences.LAYOUT_FILE_EXTENSION,""); + String subname = fileName.replace(DataHelper.LAYOUT_FILE_EXTENSION,""); //Check if it has iso: if(subname.matches("\\w+_..")){ @@ -49,7 +50,7 @@ public static String convertFileName(String fileName) { * @return Layout filename. */ public static String unconvertFileName(String representation){ - return representation.replace(" ","_") + Preferences.LAYOUT_FILE_EXTENSION; + return representation.replace(" ","_") + DataHelper.LAYOUT_FILE_EXTENSION; } /** diff --git a/app/src/main/java/net/osmtracker/util/URLCreator.java b/app/src/main/java/net/osmtracker/util/URLCreator.java index e87f5d81e..d50ee82e7 100644 --- a/app/src/main/java/net/osmtracker/util/URLCreator.java +++ b/app/src/main/java/net/osmtracker/util/URLCreator.java @@ -2,9 +2,11 @@ import android.content.Context; import android.content.SharedPreferences; -import android.preference.PreferenceManager; + +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; -import net.osmtracker.activity.Preferences; +import net.osmtracker.db.DataHelper; /** * Created by labexp on 13/12/17. @@ -66,7 +68,7 @@ public static String createLayoutFileURL(Context context, String layoutFolderNam String[] ghParams = getGithubParams(context); String url = RAW_CONTENT + ghParams[USERNAME] + "/" + ghParams[REPO] + "/" + ghParams[BRANCH] - + "/layouts/" + layoutFolderName + "/" + iso + Preferences.LAYOUT_FILE_EXTENSION; + + "/layouts/" + layoutFolderName + "/" + iso + DataHelper.LAYOUT_FILE_EXTENSION; return url; } diff --git a/app/src/main/java/net/osmtracker/view/TextNoteDialog.java b/app/src/main/java/net/osmtracker/view/TextNoteDialog.java index 6064dea12..c4cdf1835 100644 --- a/app/src/main/java/net/osmtracker/view/TextNoteDialog.java +++ b/app/src/main/java/net/osmtracker/view/TextNoteDialog.java @@ -4,10 +4,13 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; +import android.content.SharedPreferences; import android.os.Bundle; import android.view.WindowManager.LayoutParams; import android.widget.EditText; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.R; import net.osmtracker.db.TrackContentProvider; @@ -25,6 +28,7 @@ public class TextNoteDialog extends AlertDialog { * bundle key for waypoint uuid */ private static final String KEY_WAYPOINT_UUID = "WAYPOINT_UUID"; + private static final String KEY_NOTE_UUID = "NOTE_UUID"; /** * bundle key for waypoints track id @@ -40,12 +44,22 @@ public class TextNoteDialog extends AlertDialog { * Unique identifier of the waypoint this dialog working on */ private String wayPointUuid = null; - + + // Unique identifier of the note this dialog working on + private String noteUuid = null; + /** * Id of the track the dialog will add this waypoint to */ private long wayPointTrackId; - + + /** + * Id of the track the dialog will add this OSM Text note to + */ + private long noteTrackId; + + boolean saveAsWayPoint, saveAsNote = false; + private Context context; public TextNoteDialog(Context context, long trackId) { @@ -53,7 +67,8 @@ public TextNoteDialog(Context context, long trackId) { this.context = context; this.wayPointTrackId = trackId; - + this.noteTrackId = trackId; + // Text edit control for user input input = new EditText(context); @@ -62,27 +77,40 @@ public TextNoteDialog(Context context, long trackId) { this.setCancelable(true); this.setView(input); - this.setButton(context.getResources().getString(android.R.string.ok), new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { + this.setButton(DialogInterface.BUTTON_POSITIVE, + context.getString(android.R.string.ok), + (dialog, which) -> { + + + String noteText = input.getText().toString(); + + if (saveAsWayPoint) { // Track waypoint with user input text Intent intent = new Intent(OSMTracker.INTENT_UPDATE_WP); - intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, TextNoteDialog.this.wayPointTrackId); - intent.putExtra(OSMTracker.INTENT_KEY_NAME, input.getText().toString()); + intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, wayPointTrackId); + intent.putExtra(OSMTracker.INTENT_KEY_NAME, noteText); intent.putExtra(OSMTracker.INTENT_KEY_UUID, TextNoteDialog.this.wayPointUuid); intent.setPackage(getContext().getPackageName()); context.sendBroadcast(intent); } - }); - - this.setButton2(context.getResources().getString(android.R.string.cancel), new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // cancel the dialog - dialog.cancel(); + + if (saveAsNote) { + Intent noteIntent = new Intent(OSMTracker.INTENT_UPDATE_NOTE); + noteIntent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, noteTrackId); + noteIntent.putExtra(OSMTracker.INTENT_KEY_NAME, noteText); + noteIntent.putExtra(OSMTracker.INTENT_KEY_UUID, noteUuid); + noteIntent.setPackage(getContext().getPackageName()); + context.sendBroadcast(noteIntent); } }); + this.setButton(DialogInterface.BUTTON_NEGATIVE, + context.getResources().getString(android.R.string.cancel), + (dialog, which) -> { + // cancel the dialog + dialog.cancel(); + }); + this.setOnCancelListener(new DialogInterface.OnCancelListener() { @Override public void onCancel(DialogInterface dialog) { @@ -101,16 +129,52 @@ public void onCancel(DialogInterface dialog) { */ @Override protected void onStart() { - if (wayPointUuid == null) { - // there is no UUID set for the waypoint we're working on - // so we need to generate a UUID and track this point - wayPointUuid = UUID.randomUUID().toString(); - Intent intent = new Intent(OSMTracker.INTENT_TRACK_WP); - intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, wayPointTrackId); - intent.putExtra(OSMTracker.INTENT_KEY_UUID, wayPointUuid); - intent.putExtra(OSMTracker.INTENT_KEY_NAME, context.getResources().getString(R.string.gpsstatus_record_textnote)); - intent.setPackage(getContext().getPackageName()); - context.sendBroadcast(intent); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String prefSaveAs = prefs.getString( + OSMTracker.Preferences.KEY_USE_NOTES, + OSMTracker.Preferences.VAL_USE_NOTES); + switch (prefSaveAs) { + case "waypoint": + saveAsWayPoint = true; + saveAsNote = false; + break; + case "osm_note": + saveAsWayPoint = false; + saveAsNote = true; + break; + default: // Assuming "both" is the default + saveAsWayPoint = true; + saveAsNote = true; + break; + } + + if (saveAsWayPoint) { + if (wayPointUuid == null) { + // there is no UUID set for the waypoint we're working on + // so we need to generate a UUID and track this point + wayPointUuid = UUID.randomUUID().toString(); + Intent intent = new Intent(OSMTracker.INTENT_TRACK_WP); + intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, wayPointTrackId); + intent.putExtra(OSMTracker.INTENT_KEY_UUID, wayPointUuid); + intent.putExtra(OSMTracker.INTENT_KEY_NAME, context.getResources().getString(R.string.gpsstatus_record_textnote)); + intent.setPackage(getContext().getPackageName()); + context.sendBroadcast(intent); + } + } + + if (saveAsNote) { + if (noteUuid == null) { + // there is no UUID set for the note we're working on + // so we need to generate a UUID and track this note + noteUuid = UUID.randomUUID().toString(); + Intent noteIntent = new Intent(OSMTracker.INTENT_TRACK_NOTE); + noteIntent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, noteTrackId); + noteIntent.putExtra(OSMTracker.INTENT_KEY_UUID, noteUuid); + noteIntent.putExtra(OSMTracker.INTENT_KEY_NAME, context.getResources().getString(R.string.gpsstatus_record_textnote)); + noteIntent.setPackage(getContext().getPackageName()); + context.sendBroadcast(noteIntent); + } } getWindow().setSoftInputMode(LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); @@ -124,6 +188,7 @@ protected void onStart() { */ public void resetValues() { wayPointUuid = null; + noteUuid = null; input.setText(""); } @@ -138,6 +203,7 @@ public void onRestoreInstanceState(Bundle savedInstanceState) { } wayPointUuid = savedInstanceState.getString(KEY_WAYPOINT_UUID); wayPointTrackId = savedInstanceState.getLong(KEY_WAYPOINT_TRACKID); + noteUuid = savedInstanceState.getString(KEY_NOTE_UUID); super.onRestoreInstanceState(savedInstanceState); } @@ -148,8 +214,13 @@ public void onRestoreInstanceState(Bundle savedInstanceState) { public Bundle onSaveInstanceState() { Bundle extras = super.onSaveInstanceState(); extras.putString(KEY_INPUT_TEXT, input.getText().toString()); - extras.putLong(KEY_WAYPOINT_TRACKID, wayPointTrackId); - extras.putString(KEY_WAYPOINT_UUID, wayPointUuid); + if (saveAsWayPoint) { + extras.putLong(KEY_WAYPOINT_TRACKID, wayPointTrackId); + extras.putString(KEY_WAYPOINT_UUID, wayPointUuid); + } + if (saveAsNote) { + extras.putString(KEY_NOTE_UUID, noteUuid); + } return extras; } } diff --git a/app/src/main/java/net/osmtracker/view/VoiceRecDialog.java b/app/src/main/java/net/osmtracker/view/VoiceRecDialog.java index 97018ffc0..f50e8f373 100644 --- a/app/src/main/java/net/osmtracker/view/VoiceRecDialog.java +++ b/app/src/main/java/net/osmtracker/view/VoiceRecDialog.java @@ -10,11 +10,12 @@ import android.media.MediaRecorder; import android.media.MediaRecorder.OnInfoListener; import android.os.SystemClock; -import android.preference.PreferenceManager; import android.util.Log; import android.view.KeyEvent; import android.widget.Toast; +import androidx.preference.PreferenceManager; + import net.osmtracker.OSMTracker; import net.osmtracker.R; import net.osmtracker.db.DataHelper; diff --git a/app/src/main/res/layout/edit_note_dialog.xml b/app/src/main/res/layout/edit_note_dialog.xml new file mode 100644 index 000000000..5ecdfa4f1 --- /dev/null +++ b/app/src/main/res/layout/edit_note_dialog.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + +