diff --git a/.github/workflows/gradle_build.yml b/.github/workflows/gradle_build.yml index 57a43c33..8d7d86b7 100644 --- a/.github/workflows/gradle_build.yml +++ b/.github/workflows/gradle_build.yml @@ -54,6 +54,7 @@ jobs: echo "cyface.staging_password=guestPassword" >> gradle.properties echo "cyface.oauth_redirect=de.cyface.app:/oauth2redirect" >> gradle.properties echo "cyface.oauth_redirect.r4r=de.cyface.app.r4r:/oauth2redirect" >> gradle.properties + echo "cyface.oauth_redirect.digural=de.cyface.app.digural:/oauth2redirect" >> gradle.properties # Executing build here on Ubuntu stack (1/10th costs of MacOS stack) # Not using "gradle build" as we don't want to run the tests of all dependencies (e.g. backend) diff --git a/.github/workflows/gradle_connected-tests.yml b/.github/workflows/gradle_connected-tests.yml index 60561ac0..a886ef4c 100644 --- a/.github/workflows/gradle_connected-tests.yml +++ b/.github/workflows/gradle_connected-tests.yml @@ -70,6 +70,7 @@ jobs: echo "cyface.staging_password=guestPassword" >> gradle.properties echo "cyface.oauth_redirect=de.cyface.app:/oauth2redirect" >> gradle.properties echo "cyface.oauth_redirect.r4r=de.cyface.app.r4r:/oauth2redirect" >> gradle.properties + echo "cyface.oauth_redirect.digural=de.cyface.app.digural:/oauth2redirect" >> gradle.properties # Not executing build here on MacOS stack (10x costs, if private repository) # Not using "gradle build" as we don't want to run the tests of all dependencies (e.g. backend) diff --git a/.github/workflows/gradle_publish.yml b/.github/workflows/gradle_publish.yml index 954025c8..ffa60a69 100644 --- a/.github/workflows/gradle_publish.yml +++ b/.github/workflows/gradle_publish.yml @@ -56,6 +56,7 @@ jobs: # Inject OAuth redirect URIs echo "cyface.oauth_redirect=de.cyface.app:/oauth2redirect" >> gradle.properties echo "cyface.oauth_redirect.r4r=de.cyface.app.r4r:/oauth2redirect" >>gradle.properties + echo "cyface.oauth_redirect.digural=de.cyface.app.digural:/oauth2redirect" >> gradle.properties # Inject Google Maps API key echo "google.maps_api_key=${{ secrets.GOOGLE_MAPS_KEY }}" >> gradle.properties echo "google.maps_api_key.r4r=${{ secrets.GOOGLE_MAPS_KEY_R4R }}" >> gradle.properties diff --git a/backend b/backend index 6bf2903a..510552dd 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 6bf2903a0978c3737267695a29fe4b8d22369e1e +Subproject commit 510552ddd627d31c4d5c43e687bdc908afdacf1b diff --git a/build.gradle b/build.gradle index 86374e5d..12e186d4 100644 --- a/build.gradle +++ b/build.gradle @@ -55,10 +55,10 @@ ext { */ // Cyface dependencies - cyfaceAndroidBackendVersion = "7.8.0" // Also update submodule commit ref - cyfaceUtilsVersion = "3.3.7" - cyfaceEnergySettingsVersion = "3.3.3" // Also update submodule commit ref - cyfaceCameraServiceVersion = "4.1.11" // Also update submodule commit ref + cyfaceAndroidBackendVersion = "7.8.1" // Also update submodule commit ref + cyfaceUtilsVersion = "3.5.0" + cyfaceEnergySettingsVersion = "3.3.4" // Also update submodule commit ref + cyfaceCameraServiceVersion = "4.1.12" // Also update submodule commit ref // Maybe keep this in sync with the serialization library version used in `uploader` lib cyfaceSerializationVersion = "2.3.7" // Keep im sync with version in submodule `backend` cyfaceUploaderVersion = "1.0.0" diff --git a/camera_service b/camera_service index d3d2b716..d02b953b 160000 --- a/camera_service +++ b/camera_service @@ -1 +1 @@ -Subproject commit d3d2b716f7e766a56d5fb075f1f7f7eabd575af2 +Subproject commit d02b953b8b2290235aa4493305d223be09f9b6ab diff --git a/energy_settings b/energy_settings index d8c8bb97..b9ec895f 160000 --- a/energy_settings +++ b/energy_settings @@ -1 +1 @@ -Subproject commit d8c8bb97c2480ecd57afbe80bb17c45319d66bc1 +Subproject commit b9ec895fd350a325fc3f8fe680a86216520ed360 diff --git a/gradle.properties.template b/gradle.properties.template index 47538f3d..46268079 100644 --- a/gradle.properties.template +++ b/gradle.properties.template @@ -45,6 +45,7 @@ hCaptcha.key.r4r= cyface.oauth_redirect= cyface.oauth_redirect.r4r= +cyface.oauth_redirect.digural= cyface.api= cyface.incentives_api= diff --git a/settings.gradle b/settings.gradle index 53e972ef..4f0586e9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -59,6 +59,7 @@ include ':energy_settings' include ':camera_service' include ':bluetooth-le' include ':ui:cyface' +include ':ui:digural' include ':ui:r4r' project(':persistence').projectDir = new File('backend/persistence') project(':synchronization').projectDir = new File('backend/synchronization') diff --git a/ui/cyface/src/main/java/de/cyface/app/ui/button/DataCapturingButton.java b/ui/cyface/src/main/java/de/cyface/app/ui/button/DataCapturingButton.java index 19a48ec7..d41fb913 100644 --- a/ui/cyface/src/main/java/de/cyface/app/ui/button/DataCapturingButton.java +++ b/ui/cyface/src/main/java/de/cyface/app/ui/button/DataCapturingButton.java @@ -19,18 +19,6 @@ package de.cyface.app.ui.button; import static de.cyface.app.utils.Constants.TAG; -import static de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY; -import static de.cyface.app.utils.SharedConstants.PREFERENCES_MODALITY_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_DISTANCE_BASED_TRIGGERING_ENABLED_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_RAW_MODE_ENABLED_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_ENABLED_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_EXPOSURE_VALUE_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_DISTANCE_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_ENABLED_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_TRIGGERING_DISTANCE_KEY; -import static de.cyface.camera_service.Constants.PREFERENCES_CAMERA_VIDEO_MODE_ENABLED_KEY; import static de.cyface.datacapturing.DataCapturingService.IS_RUNNING_CALLBACK_TIMEOUT; import static de.cyface.energy_settings.TrackingSettings.isBackgroundProcessingRestricted; import static de.cyface.energy_settings.TrackingSettings.isEnergySaferActive; @@ -56,7 +44,6 @@ import android.app.Activity; import android.app.ProgressDialog; import android.content.Context; -import android.content.SharedPreferences; import android.location.LocationManager; import android.os.Build; import android.os.Handler; @@ -69,7 +56,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.preference.PreferenceManager; import de.cyface.app.CapturingFragment; import de.cyface.app.R; @@ -78,6 +64,7 @@ import de.cyface.app.utils.CalibrationDialogListener; import de.cyface.app.utils.Map; import de.cyface.camera_service.CameraListener; +import de.cyface.camera_service.CameraPreferences; import de.cyface.camera_service.CameraService; import de.cyface.camera_service.Constants; import de.cyface.camera_service.UIListener; @@ -101,6 +88,7 @@ import de.cyface.persistence.model.ParcelableGeoLocation; import de.cyface.persistence.model.Track; import de.cyface.persistence.strategy.DefaultLocationCleaning; +import de.cyface.utils.AppPreferences; import de.cyface.utils.DiskConsumption; import de.cyface.utils.Validate; import io.sentry.Sentry; @@ -132,7 +120,14 @@ public class DataCapturingButton * The {@link CameraService} required to control and check the visual capturing process. */ private CameraService cameraService = null; - private SharedPreferences preferences; + /** + * The `SharedPreferences` used to store the app preferences. + */ + private AppPreferences preferences; + /** + * The `SharedPreferences` used to store the camera preferences. + */ + private CameraPreferences cameraPreferences; private final static long CALIBRATION_DIALOG_TIMEOUT = 1500L; private Collection calibrationDialogListener; /** @@ -191,8 +186,9 @@ public void onCreateView(final ImageButton button, final DonutProgress ISNULL) { this.cameraInfoTextView = button.getRootView().findViewById(R.id.camera_capturing_info); // To get the vehicle - preferences = PreferenceManager.getDefaultSharedPreferences(context); - isReportingEnabled = preferences.getBoolean(ACCEPTED_REPORTING_KEY, false); + preferences = new AppPreferences(context); + cameraPreferences = new CameraPreferences(context); + isReportingEnabled = preferences.getReportingAccepted(); // To load the measurement distance this.persistenceLayer = new DefaultPersistenceLayer<>(context, new DefaultPersistenceBehaviour()); @@ -564,7 +560,7 @@ private void startCapturing() { // TODO [CY-3855]: we have to provide a listener for the button (<- ???) try { - final Modality modality = Modality.valueOf(preferences.getString(PREFERENCES_MODALITY_KEY, null)); + final var modality = Modality.valueOf(preferences.getModality()); Validate.notNull(modality); currentMeasurementsTracks = new ArrayList<>(); @@ -691,26 +687,17 @@ private void showToastOnMainThread(final String toastMessage, final boolean long private void startCameraService(final long measurementId) throws DataCapturingException, MissingPermissionException { - final boolean rawModeSelected = preferences.getBoolean(PREFERENCES_CAMERA_RAW_MODE_ENABLED_KEY, false); - final boolean videoModeSelected = preferences.getBoolean(PREFERENCES_CAMERA_VIDEO_MODE_ENABLED_KEY, false); + final var rawModeSelected = cameraPreferences.getRawMode(); + final var videoModeSelected = cameraPreferences.getVideoMode(); // We need to load and pass the preferences for the camera focus here as the preferences // do not work reliably on multi-process access. https://stackoverflow.com/a/27987956/5815054 - final boolean staticFocusSelected = preferences.getBoolean(PREFERENCES_CAMERA_STATIC_FOCUS_ENABLED_KEY, - false); - final float staticFocusDistance = preferences.getFloat(PREFERENCES_CAMERA_STATIC_FOCUS_DISTANCE_KEY, - Constants.DEFAULT_STATIC_FOCUS_DISTANCE); - final boolean distanceBasedTriggeringSelected = preferences.getBoolean( - PREFERENCES_CAMERA_DISTANCE_BASED_TRIGGERING_ENABLED_KEY, - true); - final float triggeringDistance = preferences.getFloat(PREFERENCES_CAMERA_TRIGGERING_DISTANCE_KEY, - Constants.DEFAULT_TRIGGERING_DISTANCE); - final boolean staticExposureTimeSelected = preferences.getBoolean( - PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_ENABLED_KEY, - false); - final long staticExposureTime = preferences.getLong(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, - Constants.DEFAULT_STATIC_EXPOSURE_TIME); - final int exposureValueIso100 = preferences.getInt(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_EXPOSURE_VALUE_KEY, - Constants.DEFAULT_STATIC_EXPOSURE_VALUE_ISO_100); + final var staticFocusSelected = cameraPreferences.getStaticFocus(); + final var staticFocusDistance = cameraPreferences.getStaticFocusDistance(); + final var distanceBasedTriggeringSelected = cameraPreferences.getDistanceBasedTriggering(); + final var triggeringDistance = cameraPreferences.getTriggeringDistance(); + final var staticExposureTimeSelected = cameraPreferences.getStaticExposure(); + final var staticExposureTime = cameraPreferences.getStaticExposureTime(); + final var exposureValueIso100 = cameraPreferences.getStaticExposureValue(); cameraService.start(measurementId, videoModeSelected, rawModeSelected, staticFocusSelected, staticFocusDistance, staticExposureTimeSelected, staticExposureTime, exposureValueIso100, @@ -869,7 +856,7 @@ public void addButtonListener(final ButtonListener buttonListener) { } private boolean isCameraServiceRequested() { - return preferences.getBoolean(PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, false); + return cameraPreferences.getCameraEnabled(); } @Override diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/CapturingFragment.kt b/ui/cyface/src/main/kotlin/de/cyface/app/CapturingFragment.kt index 835329ef..30cea6af 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/CapturingFragment.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/CapturingFragment.kt @@ -19,9 +19,7 @@ package de.cyface.app import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle -import android.preference.PreferenceManager import android.util.Log import android.view.LayoutInflater import android.view.View @@ -41,8 +39,6 @@ import de.cyface.app.dialog.ModalityDialog import de.cyface.app.ui.button.DataCapturingButton import de.cyface.app.utils.Map import de.cyface.app.utils.ServiceProvider -import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY -import de.cyface.app.utils.SharedConstants.PREFERENCES_MODALITY_KEY import de.cyface.camera_service.CameraService import de.cyface.datacapturing.CyfaceDataCapturingService import de.cyface.datacapturing.persistence.CapturingPersistenceBehaviour @@ -51,6 +47,7 @@ import de.cyface.persistence.exception.NoSuchMeasurementException import de.cyface.persistence.model.Event import de.cyface.persistence.model.Modality import de.cyface.synchronization.ConnectionStatusListener +import de.cyface.utils.AppPreferences import de.cyface.utils.Validate import io.sentry.Sentry @@ -91,9 +88,9 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { private set /** - * The `SharedPreferences` used to store the user's preferences. + * The `SharedPreferences` used to store the app preferences. */ - private var preferences: SharedPreferences? = null + private lateinit var preferences: AppPreferences /** * The `DataCapturingService` which represents the API of the Cyface Android SDK. @@ -150,8 +147,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { currentMeasurementsEvents = dataCapturingButton!!.loadCurrentMeasurementsEvents() map!!.render(currentMeasurementsTracks, currentMeasurementsEvents, false, ArrayList()) } catch (e: NoSuchMeasurementException) { - val isReportingEnabled = preferences!!.getBoolean(ACCEPTED_REPORTING_KEY, false) - if (isReportingEnabled) { + if (preferences.getReportingAccepted()) { Sentry.captureException(e) } Log.w( @@ -184,7 +180,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { ): View { _binding = FragmentCapturingBinding.inflate(inflater, container, false) dataCapturingButton = DataCapturingButton(this) - preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + preferences = AppPreferences(requireContext()) // Register synchronization listener capturing.addConnectionStatusListener(this) syncButton = SynchronizationButton(capturing) @@ -224,8 +220,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { private fun showModalitySelectionDialogIfNeeded() { registerModalityTabSelectionListener() - val selectedModality = preferences!!.getString(PREFERENCES_MODALITY_KEY, null) - if (selectedModality != null) { + if (preferences.getModality() != null) { selectModalityTab() return } @@ -242,7 +237,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { val newModality = arrayOfNulls(1) tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { - val oldModalityId = preferences!!.getString(PREFERENCES_MODALITY_KEY, null) + val oldModalityId = preferences.getModality() val oldModality = if (oldModalityId == null) null else Modality.valueOf(oldModalityId) when (tab.position) { @@ -253,9 +248,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { 4 -> newModality[0] = Modality.TRAIN else -> throw IllegalArgumentException("Unknown tab selected: " + tab.position) } - preferences!!.edit() - .putString(PREFERENCES_MODALITY_KEY, newModality[0]!!.databaseIdentifier) - .apply() + preferences.saveModality(newModality[0]!!.databaseIdentifier) if (oldModality != null && oldModality == newModality[0]) { Log.d( TAG, @@ -305,7 +298,7 @@ class CapturingFragment : Fragment(), ConnectionStatusListener { */ private fun selectModalityTab() { val tabLayout = binding.modalityTabs - val modality = preferences!!.getString(PREFERENCES_MODALITY_KEY, null) + val modality = preferences.getModality() Validate.notNull(modality, "Modality should already be set but isn't.") // Select the Modality tab diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt index a56f8081..4d47ab75 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/MainActivity.kt @@ -26,12 +26,12 @@ import android.accounts.AuthenticatorException import android.accounts.OperationCanceledException import android.app.Activity import android.content.Intent -import android.content.SharedPreferences import android.content.pm.PackageManager +import android.location.Location import android.os.Bundle import android.os.Handler import android.os.Message -import android.preference.PreferenceManager +import android.os.Parcel import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -52,11 +52,8 @@ import de.cyface.app.utils.Constants import de.cyface.app.utils.Constants.ACCOUNT_TYPE import de.cyface.app.utils.Constants.AUTHORITY import de.cyface.app.utils.ServiceProvider -import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY -import de.cyface.app.utils.SharedConstants.DEFAULT_SENSOR_FREQUENCY -import de.cyface.app.utils.SharedConstants.PREFERENCES_SENSOR_FREQUENCY_KEY -import de.cyface.app.utils.SharedConstants.PREFERENCES_SYNCHRONIZATION_KEY import de.cyface.camera_service.CameraListener +import de.cyface.camera_service.CameraPreferences import de.cyface.camera_service.CameraService import de.cyface.datacapturing.CyfaceDataCapturingService import de.cyface.datacapturing.DataCapturingListener @@ -72,6 +69,7 @@ import de.cyface.synchronization.OAuth2 import de.cyface.synchronization.OAuth2.Companion.END_SESSION_REQUEST_CODE import de.cyface.synchronization.WiFiSurveyor import de.cyface.uploader.exception.SynchronisationException +import de.cyface.utils.AppPreferences import de.cyface.utils.DiskConsumption import de.cyface.utils.Validate import io.sentry.Sentry @@ -117,9 +115,14 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider private lateinit var navigation: NavController /** - * The `SharedPreferences` used to store the user's preferences. + * The `SharedPreferences` used to store the app preferences. */ - private var preferences: SharedPreferences? = null + private lateinit var appPreferences: AppPreferences + + /** + * The `SharedPreferences` used to store the camera preferences. + */ + private lateinit var cameraPreferences: CameraPreferences /** * The authorization. @@ -159,15 +162,13 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider } override fun onCreate(savedInstanceState: Bundle?) { - preferences = PreferenceManager.getDefaultSharedPreferences(this) + appPreferences = AppPreferences(this) + cameraPreferences = CameraPreferences(this) // Location permissions are requested by MainFragment which needs to react to results // If camera service is requested, check needed permissions - val cameraEnabled = preferences!!.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, - false - ) + val cameraEnabled = cameraPreferences.getCameraEnabled() val permissionsMissing = ContextCompat.checkSelfPermission( this, Manifest.permission.CAMERA @@ -191,8 +192,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider } // Start DataCapturingService and CameraService - val sensorFrequency = - preferences!!.getInt(PREFERENCES_SENSOR_FREQUENCY_KEY, DEFAULT_SENSOR_FREQUENCY) + val sensorFrequency = appPreferences.getSensorFrequency() try { capturing = CyfaceDataCapturingService( this.applicationContext, @@ -211,7 +211,18 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider cameraService = CameraService( this.applicationContext, CameraEventHandler(), - unInterestedCameraListener // here was the capturing button but it registers itself, too + unInterestedCameraListener, // here was the capturing button but it registers itself, too + object { + override fun describeContents(): Int { + return 0 + } + override fun writeToParcel(dest: Parcel, flags: Int) { + // nothing to do + } + override fun trigger(measurementId: Long, location: Location?) { + // nothing to do + } + } ) } catch (e: SetupException) { throw IllegalStateException(e) @@ -317,8 +328,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider capturing.shutdownDataCapturingService() // Before we only called: shutdownConnectionStatusReceiver(); } catch (e: SynchronisationException) { - val isReportingEnabled = preferences!!.getBoolean(ACCEPTED_REPORTING_KEY, false) - if (isReportingEnabled) { + if (appPreferences.getReportingAccepted()) { Sentry.captureException(e) } Log.w(TAG, "Failed to shut down CyfaceDataCapturingService. ", e) @@ -368,8 +378,7 @@ class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider Validate.notNull(account) // Set synchronizationEnabled to the current user preferences - val syncEnabledPreference = preferences!! - .getBoolean(PREFERENCES_SYNCHRONIZATION_KEY, true) + val syncEnabledPreference = appPreferences.getUpload() Log.d( WiFiSurveyor.TAG, "Setting syncEnabled for new account to preference: $syncEnabledPreference" diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt b/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt index f81e5a91..eabdfa51 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/MeasuringClient.kt @@ -20,15 +20,13 @@ package de.cyface.app import android.app.Application import android.content.IntentFilter -import android.content.SharedPreferences -import android.preference.PreferenceManager import android.widget.Toast import androidx.localbroadcastmanager.content.LocalBroadcastManager import de.cyface.app.auth.LoginActivity -import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY import de.cyface.synchronization.CyfaceAuthenticator import de.cyface.synchronization.ErrorHandler import de.cyface.synchronization.ErrorHandler.ErrorCode +import de.cyface.utils.AppPreferences import io.sentry.Sentry /** @@ -45,7 +43,7 @@ class MeasuringClient : Application() { /** * Stores the user's preferences. */ - private var preferences: SharedPreferences? = null + private lateinit var preferences: AppPreferences /** * Reports error events to the user via UI and to Sentry, if opted-in. @@ -66,15 +64,14 @@ class MeasuringClient : Application() { // but in the second case we cannot get the stacktrace as it's only available in the SDK. // For that reason we also capture a message here. // However, it seems like e.g. a interrupted upload shows a toast but does not trigger sentry. - val isReportingEnabled = preferences!!.getBoolean(ACCEPTED_REPORTING_KEY, false) - if (isReportingEnabled) { + if (preferences.getReportingAccepted()) { Sentry.captureMessage(errorCode.name + ": " + errorMessage) } } override fun onCreate() { super.onCreate() - preferences = PreferenceManager.getDefaultSharedPreferences(this) + preferences = AppPreferences(this) // Register the activity to be called by the authenticator to request credentials from the user. CyfaceAuthenticator.LOGIN_ACTIVITY = LoginActivity::class.java diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/TermsOfUseActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/TermsOfUseActivity.kt index cea414e9..1a3495cd 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/TermsOfUseActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/TermsOfUseActivity.kt @@ -20,15 +20,12 @@ package de.cyface.app import android.app.Activity import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle -import android.preference.PreferenceManager import android.view.View import android.widget.Button import android.widget.CheckBox import android.widget.CompoundButton -import de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY -import de.cyface.app.utils.SharedConstants.ACCEPTED_TERMS_KEY +import de.cyface.utils.AppPreferences /** * The TermsOfUserActivity is the first [Activity] started on app launch. @@ -60,7 +57,7 @@ class TermsOfUseActivity : Activity(), View.OnClickListener { /** * To check whether the user accepted the terms and opted-in to error reporting. */ - private var preferences: SharedPreferences? = null + private lateinit var preferences: AppPreferences /** * Allows the user to opt-in to error reporting. @@ -73,9 +70,7 @@ class TermsOfUseActivity : Activity(), View.OnClickListener { private var acceptTermsCheckbox: CheckBox? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - preferences = PreferenceManager.getDefaultSharedPreferences( - applicationContext - ) + preferences = AppPreferences(applicationContext) callMainActivityIntent = Intent(this, MainActivity::class.java) if (currentTermsHadBeenAccepted()) { startActivity(callMainActivityIntent) @@ -89,8 +84,7 @@ class TermsOfUseActivity : Activity(), View.OnClickListener { * @return `True` if the latest privacy policy was accepted by the user. */ private fun currentTermsHadBeenAccepted(): Boolean { - val acceptedTermsVersion = preferences!!.getInt(ACCEPTED_TERMS_KEY, 0) - return acceptedTermsVersion == BuildConfig.currentTerms + return preferences.getAcceptedTerms() == BuildConfig.currentTerms } /** @@ -121,10 +115,8 @@ class TermsOfUseActivity : Activity(), View.OnClickListener { } override fun onClick(view: View) { - val editor = preferences!!.edit() - editor.putInt(ACCEPTED_TERMS_KEY, BuildConfig.currentTerms) - editor.putBoolean(ACCEPTED_REPORTING_KEY, isReportingEnabled) - editor.apply() + preferences.saveAcceptedTerms(BuildConfig.currentTerms) + preferences.saveReportingAccepted(isReportingEnabled) this.startActivity(callMainActivityIntent) finish() } diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt b/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt index 8025e2fd..1068a35f 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/auth/LoginActivity.kt @@ -68,7 +68,7 @@ import java.util.concurrent.atomic.AtomicReference */ class LoginActivity : AppCompatActivity() { - private val TAG = "de.cyface.app.r4r.login" + private val TAG = "de.cyface.app.login" private val EXTRA_FAILED = "failed" private val RC_AUTH = 100 diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/button/SynchronizationButton.kt b/ui/cyface/src/main/kotlin/de/cyface/app/button/SynchronizationButton.kt index 5939eca5..33a44d54 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/button/SynchronizationButton.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/button/SynchronizationButton.kt @@ -20,8 +20,6 @@ package de.cyface.app.button import android.accounts.AccountManager import android.content.Context -import android.content.SharedPreferences -import android.preference.PreferenceManager import android.util.Log import android.view.View import android.widget.ImageButton @@ -30,9 +28,9 @@ import com.github.lzyzsd.circleprogress.DonutProgress import de.cyface.app.MainActivity.Companion.accountWithTokenExists import de.cyface.app.utils.Constants.TAG import de.cyface.app.utils.R -import de.cyface.app.utils.SharedConstants.PREFERENCES_SYNCHRONIZATION_KEY import de.cyface.datacapturing.CyfaceDataCapturingService import de.cyface.synchronization.WiFiSurveyor +import de.cyface.utils.AppPreferences import de.cyface.utils.Validate /** @@ -56,7 +54,7 @@ class SynchronizationButton(dataCapturingService: CyfaceDataCapturingService) : */ private var button: ImageButton? = null private var progressView: DonutProgress? = null - private var preferences: SharedPreferences? = null + private lateinit var preferences: AppPreferences /** * [CyfaceDataCapturingService] to check [WiFiSurveyor.isConnected] @@ -73,7 +71,7 @@ class SynchronizationButton(dataCapturingService: CyfaceDataCapturingService) : context = button!!.context this.button = button this.progressView = progress - preferences = PreferenceManager.getDefaultSharedPreferences(context) + preferences = AppPreferences(context!!) onResume() // TODO[MOV-621] the parent's onResume, thus, this class's onResume should automatically be called button.setOnClickListener(this) } @@ -144,11 +142,10 @@ class SynchronizationButton(dataCapturingService: CyfaceDataCapturingService) : // Check is sync is disabled via frontend val syncEnabled = dataCapturingService.wiFiSurveyor.isSyncEnabled - val syncPreferenceEnabled = preferences!!.getBoolean(PREFERENCES_SYNCHRONIZATION_KEY, true) + val syncPreferenceEnabled = preferences.getUpload() Validate.isTrue( syncEnabled == syncPreferenceEnabled, - "sync " + (if (syncEnabled) "enabled" else "disabled") - + " but syncPreference " + if (syncPreferenceEnabled) "enabled" else "disabled" + "sync enabled=$syncEnabled but syncPreference enabled=$syncPreferenceEnabled" ) if (!syncEnabled) { Toast.makeText( diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/SettingsFragment.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/SettingsFragment.kt deleted file mode 100644 index 0eb64c84..00000000 --- a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/SettingsFragment.kt +++ /dev/null @@ -1,1276 +0,0 @@ -/* - * Copyright 2023 Cyface GmbH - * - * This file is part of the Cyface App for Android. - * - * The Cyface App for Android is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Cyface App for Android is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with the Cyface App for Android. If not, see . - */ -package de.cyface.app.capturing - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.preference.PreferenceManager -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import android.widget.Toast -import androidx.fragment.app.Fragment -import de.cyface.app.databinding.FragmentSettingsBinding -import de.cyface.app.dialog.ExposureTimeDialog -import de.cyface.app.utils.Constants -import de.cyface.app.utils.ServiceProvider -import de.cyface.app.utils.SharedConstants.PREFERENCES_CENTER_MAP_KEY -import de.cyface.app.utils.SharedConstants.PREFERENCES_SYNCHRONIZATION_KEY -import de.cyface.datacapturing.CyfaceDataCapturingService - -/** - * The [Fragment] which shows the settings to the user. - * - * @author Armin Schnabel - * @version 1.0.0 - * @since 3.2.0 - */ -class SettingsFragment : Fragment() { - - /** - * This property is only valid between onCreateView and onDestroyView. - */ - private var _binding: FragmentSettingsBinding? = null - - /** - * The generated class which holds all bindings from the layout file. - */ - private val binding get() = _binding!! - - /** - * The capturing service object which controls data capturing and synchronization. - */ - private lateinit var capturing: CyfaceDataCapturingService - - /** - * The preferences used to store the user's preferred settings. - */ - private lateinit var preferences: SharedPreferences - - /** - * The {@code String} which represents the hardware camera focus distance calibration level. - * / - private val FOCUS_DISTANCE_NOT_CALIBRATED = "uncalibrated" - - /** - * The unit of the [.staticExposureTimePreference] value shown in the **UI**. - */ - private val EXPOSURE_TIME_UNIT = "s (click on time to change)" - - /** - * The unit of the [.triggerDistancePreference] value shown in the **UI**. - */ - private val TRIGGER_DISTANCE_UNIT = "m" - - /** - * {@code True} if the camera allows to control the sensors (focus, exposure, etc.) manually. - */ - private var manualSensorSupported: Boolean = false*/ - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (activity is ServiceProvider) { - capturing = (activity as ServiceProvider).capturing - } else { - throw RuntimeException("Context does not support the Fragment, implement ServiceProvider") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsBinding.inflate(inflater, container, false) - this.preferences = PreferenceManager.getDefaultSharedPreferences(context) - - // Auto center map - binding.centerMapSwitch.setOnCheckedChangeListener( - CenterMapSwitchHandler( - preferences, - context - ) - ) - binding.uploadSwitch.setOnCheckedChangeListener( - UploadSwitchHandler( - preferences, - context, - capturing - ) - ) - - // Static Focus Distance - /*binding.staticFocusSwitcher.setOnCheckedChangeListener( - StaticFocusSwitcherHandler( - requireContext(), - preferences, - manualSensorSupported, - binding.staticFocusSwitcher, - binding.staticFocusDistanceSlider, - binding.staticFocusDistance, - binding.staticFocusUnit - ) - ) - binding.staticFocusDistanceSlider.addOnChangeListener( - StaticFocusDistanceSliderHandler( - preferences, - binding.staticFocusDistance - ) - ) - val characteristics = loadCameraCharacteristics() - val minFocusDistance = - characteristics!!.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) - // is null when camera permissions are missing, is 0.0 when "lens is fixed-focus" (e.g. emulators) - // It's ok when this is not set in those cases as the fragment informs about missing manual focus mode - if (minFocusDistance != null && minFocusDistance.toDouble() != 0.0) { - binding.staticFocusDistanceSlider.valueTo = minFocusDistance - } - - // Distance based triggering - binding.distanceBasedSwitcher.setOnCheckedChangeListener( - DistanceBasedSwitcherHandler( - requireContext(), - preferences, - binding.distanceBasedSlider, - binding.distanceBased, - binding.distanceBasedUnit - ) - ) - binding.distanceBasedSlider.addOnChangeListener( - TriggerDistanceSliderHandler( - preferences, - binding.distanceBased - ) - ) - // triggerDistanceSlider.setValueTo(minFocusDistance); - // triggerDistanceSlider.setValueTo(minFocusDistance); - binding.distanceBasedUnit.text = TRIGGER_DISTANCE_UNIT - - // Static Exposure Time - binding.staticExposureTimeSwitcher.setOnCheckedChangeListener( - StaticExposureTimeSwitcherHandler( - preferences, - requireContext(), - manualSensorSupported, - binding.staticExposureTimeSwitcher, - binding.staticExposureTime, - binding.staticExposureTimeUnit, - binding.staticExposureValueTitle, - binding.staticExposureValueSlider, - binding.staticExposureValue, - binding.staticExposureValueDescription - ) - ) - // staticExposureTimeSlider = view.findViewById(R.id.camera_settings_static_exposure_time_slider); - // staticExposureTimeSlider.setOnChangeListener(new StaticExposureTimeSliderHandler()); - binding.staticExposureTime.setOnClickListener( - StaticExposureTimeClickHandler( - parentFragmentManager, - this - ) - ) - binding.staticExposureTimeUnit.text = EXPOSURE_TIME_UNIT - - // Static Exposure Value - /* - * final Range exposureTimeRange = characteristics - * .get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE); - */ - /* - * if (exposureTimeRange != null) { // when camera permissions are missing - * // Limit maximal time to 1/100s for easy motion-blur-reduced ranges: 1/125s and less - * final float toValue = 10_000_000;//Math.min(10_000_000, exposureTimeRange.getUpper()); - * final float fromValue = 1_250_000;//exposureTimeRange.getLower(); - * staticExposureTimeSlider.setValueTo(toValue); - * staticExposureTimeSlider.setValueFrom(fromValue); - * Log.d(TAG, "exposureTimeRange: " + exposureTimeRange + " ns -> set slide range: " + fromValue + " - " - * + toValue); - * } - */ - binding.staticExposureValueSlider.addOnChangeListener( - StaticExposureValueSliderHandler( - preferences, - binding.staticExposureValue, - binding.staticExposureValueDescription - ) - ) - binding.staticExposureValueSlider.valueFrom = EXPOSURE_VALUES.firstKey()!!.toFloat() - binding.staticExposureValueSlider.valueTo = EXPOSURE_VALUES.lastKey()!!.toFloat() - - // Show supported camera features - manualSensorSupported = Utils.isFeatureSupported( - characteristics, - CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR - ) - displaySupportLevelsAndUnits(characteristics, manualSensorSupported, minFocusDistance!!) - - // Sensor frequency - binding.sensorFrequencySlider.addOnChangeListener( - SensorFrequencySliderHandler( - preferences, - binding.sensorFrequency - ) - )*/ - - return binding.root - } - - override fun onResume() { - super.onResume() - updateViewToPreference() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - /** - * Updates the view to the current preferences. This can be used to initialize the view. - */ - private fun updateViewToPreference() { - - // Center map- and synchronization switch - binding.centerMapSwitch.isChecked = - preferences.getBoolean(PREFERENCES_CENTER_MAP_KEY, true) - binding.uploadSwitch.isChecked = - preferences.getBoolean(PREFERENCES_SYNCHRONIZATION_KEY, true) - - /* - // Update camera enabled and mode status view if incorrect - val cameraModeEnabledPreferred = preferences.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, - false - ) - val videoModePreferred = preferences.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_VIDEO_MODE_ENABLED_KEY, - false - ) - val rawModePreferred = preferences.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_RAW_MODE_ENABLED_KEY, - false - ) - val cameraModeText = - if (videoModePreferred) "Video" else if (rawModePreferred) "DNG" else "JPEG" - val cameraStatusText = - if (!cameraModeEnabledPreferred) "disabled" else "enabled, $cameraModeText mode" - if (binding.cameraStatus.text !== cameraStatusText) { - Log.d(TAG, "updateView -> camera mode view $cameraStatusText") - binding.cameraStatus.text = cameraStatusText - } - updateDistanceBasedTriggeringViewToPreference() - - // Update sensor frequency slider view if incorrect - val preferredSensorFrequency = preferences.getInt( - PREFERENCES_SENSOR_FREQUENCY_KEY, - DEFAULT_SENSOR_FREQUENCY - ) - if (binding.sensorFrequencySlider.value != preferredSensorFrequency.toFloat()) { - Log.d(TAG,"updateView -> sensor frequency slider $preferredSensorFrequency") - binding.sensorFrequencySlider.value = preferredSensorFrequency.toFloat() - } - if (binding.sensorFrequency.text.isEmpty() - || binding.sensorFrequency.text.toString().toInt() != preferredSensorFrequency - ) { - Log.d(TAG,"updateView -> sensor frequency text $preferredSensorFrequency") - binding.sensorFrequency.text = preferredSensorFrequency.toString() - } - - // Only check manual sensor settings if it's supported or else the app crashes - if (manualSensorSupported) { - updateManualSensorViewToPreferences() - } else { - binding.staticFocusDistanceSlider.visibility = View.INVISIBLE - // staticExposureTimeSlider.setVisibility(View.INVISIBLE); - binding.staticExposureValueSlider.visibility = View.INVISIBLE - }*/ - } - - /** - * Returns the features supported by the camera hardware. - * - * @return The hardware feature support - * / - private fun loadCameraCharacteristics(): CameraCharacteristics? { - val cameraManager = - requireContext().getSystemService(Context.CAMERA_SERVICE) as CameraManager - return try { - Validate.notNull(cameraManager) - val cameraId = Utils.getMainRearCameraId(cameraManager, null) - cameraManager.getCameraCharacteristics(cameraId) - } catch (e: CameraAccessException) { - throw IllegalStateException(e) - } - } - - private fun updateDistanceBasedTriggeringViewToPreference() { - // Update slider view if incorrect - val preferredTriggerDistance = preferences.getFloat( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_TRIGGERING_DISTANCE_KEY, - de.cyface.camera_service.Constants.DEFAULT_TRIGGERING_DISTANCE - ) - val roundedDistance = Math.round(preferredTriggerDistance * 100) / 100f - if (binding.distanceBasedSlider.getValue() != roundedDistance) { - Log.d( - TAG, - "updateView -> triggering distance slider $roundedDistance" - ) - binding.distanceBasedSlider.setValue(roundedDistance) - } - if (binding.distanceBased.getText().length == 0 - || binding.distanceBased.getText().toString().toFloat() != roundedDistance - ) { - Log.d( - TAG, - "updateView -> triggering distance text $roundedDistance" - ) - binding.distanceBased.setText(roundedDistance.toString()) - } - - // Update switcher view if incorrect - val distanceBasedTriggeringPreferred = preferences.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_DISTANCE_BASED_TRIGGERING_ENABLED_KEY, - true - ) - if (binding.distanceBasedSwitcher.isChecked() != distanceBasedTriggeringPreferred) { - Log.d( - TAG, - "updateView distance based triggering switcher -> $distanceBasedTriggeringPreferred" - ) - binding.distanceBasedSwitcher.setChecked(distanceBasedTriggeringPreferred) - } - - // Update visibility slider + distance field - val expectedTriggerDistanceVisibility = - if (distanceBasedTriggeringPreferred) View.VISIBLE else View.INVISIBLE - Log.d( - TAG, - "view status -> distance triggering fields are " + binding.distanceBasedSlider.getVisibility() - + "expected is: " - + if (expectedTriggerDistanceVisibility == View.VISIBLE) "visible" else "invisible" - ) - if (binding.distanceBasedSlider.getVisibility() != expectedTriggerDistanceVisibility || binding.distanceBased.getVisibility() != expectedTriggerDistanceVisibility || binding.distanceBasedUnit.getVisibility() != expectedTriggerDistanceVisibility) { - Log.d( - TAG, "updateView -> distance triggering fields to " - + if (expectedTriggerDistanceVisibility == View.VISIBLE) "visible" else "invisible" - ) - binding.distanceBasedSlider.setVisibility(expectedTriggerDistanceVisibility) - binding.distanceBased.setVisibility(expectedTriggerDistanceVisibility) - binding.distanceBasedUnit.setVisibility(expectedTriggerDistanceVisibility) - } - } - - private fun updateManualSensorViewToPreferences() { - // Update focus distance slider view if incorrect - val preferredFocusDistance = preferences.getFloat( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_DISTANCE_KEY, - de.cyface.camera_service.Constants.DEFAULT_STATIC_FOCUS_DISTANCE - ) - val roundedDistance = Math.round(preferredFocusDistance * 100) / 100f - if (binding.staticFocusDistanceSlider.getValue() != roundedDistance) { - Log.d( - TAG, - "updateView -> focus distance slider $roundedDistance" - ) - binding.staticFocusDistanceSlider.setValue(roundedDistance) - } - if (binding.staticFocusDistance.getText().length == 0 - || binding.staticFocusDistance.getText().toString().toFloat() != roundedDistance - ) { - Log.d( - TAG, - "updateView -> focus distance text $roundedDistance" - ) - binding.staticFocusDistance.setText(roundedDistance.toString()) - } - - // Update exposure time slider view if incorrect - val preferredExposureTimeNanos = preferences.getLong( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, - de.cyface.camera_service.Constants.DEFAULT_STATIC_EXPOSURE_TIME - ) - /* - * if (staticExposureTimeSlider.getValue() != preferredExposureTimeNanos) { - * Log.d(TAG, "updateView -> exposure time slider " + preferredExposureTimeNanos); - * staticExposureTimeSlider.setValue(preferredExposureTimeNanos); - * } - */if (binding.staticExposureTime.getText().length == 0 - || binding.staticExposureTime.getText().toString() != Utils.getExposureTimeFraction( - preferredExposureTimeNanos - ) - ) { - Log.d( - TAG, - "updateView -> exposure time text $preferredExposureTimeNanos" - ) - binding.staticExposureTime.setText( - Utils.getExposureTimeFraction( - preferredExposureTimeNanos - ) - ) - } - - // Update exposure value slider view if incorrect - val preferredExposureValue = preferences.getInt( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_EXPOSURE_VALUE_KEY, - de.cyface.camera_service.Constants.DEFAULT_STATIC_EXPOSURE_VALUE_ISO_100 - ) - if (binding.staticExposureValueSlider.value != preferredExposureValue.toFloat()) { - Log.d(TAG, "updateView -> exposure value slider $preferredExposureValue") - binding.staticExposureValueSlider.value = preferredExposureValue.toFloat() - } - if (binding.staticExposureValue.text.isEmpty() || binding.staticExposureValue.text.toString() - .toInt() != preferredExposureValue - ) { - Log.d(TAG, "updateView -> exposure value text $preferredExposureValue") - binding.staticExposureValue.text = preferredExposureValue.toString() - } - // Update exposure value description view if incorrect - val expectedExposureValueDescription = EXPOSURE_VALUES.get(preferredExposureValue) - if (binding.staticExposureValueDescription.text != expectedExposureValueDescription) { - Log.d( - TAG, - "updateView -> exposure value description $expectedExposureValueDescription" - ) - binding.staticExposureValueDescription.text = expectedExposureValueDescription - } - - // Update focus distance switcher view if incorrect - val focusDistancePreferred = preferences.getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_ENABLED_KEY, - false - ) - if (binding.staticFocusSwitcher.isChecked != focusDistancePreferred) { - Log.d( - TAG, - "updateView focus distance switcher -> $focusDistancePreferred" - ) - binding.staticFocusSwitcher.isChecked = focusDistancePreferred - } - - // Update exposure time switcher view if incorrect - val exposureTimePreferred = preferences - .getBoolean( - de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_ENABLED_KEY, - false - ) - if (binding.staticExposureTimeSwitcher.isChecked != exposureTimePreferred) { - Log.d( - TAG, - "updateView exposure time switcher -> $exposureTimePreferred" - ) - binding.staticExposureTimeSwitcher.isChecked = exposureTimePreferred - } - - // Update visibility of focus distance slider + distance field - val expectedFocusDistanceVisibility = - if (focusDistancePreferred) View.VISIBLE else View.INVISIBLE - if (binding.staticFocusDistanceSlider.visibility != expectedFocusDistanceVisibility || binding.staticFocusDistance.getVisibility() != expectedFocusDistanceVisibility || binding.staticFocusUnit.getVisibility() != expectedFocusDistanceVisibility) { - Log.d( - TAG, "updateView -> focus distance fields to " - + if (expectedFocusDistanceVisibility == View.VISIBLE) "visible" else "invisible" - ) - binding.staticFocusDistanceSlider.visibility = expectedFocusDistanceVisibility - binding.staticFocusDistance.visibility = expectedFocusDistanceVisibility - binding.staticFocusUnit.visibility = expectedFocusDistanceVisibility - } - - // Update visibility of exposure time and value slider + time and value fields - val expectedExposureTimeVisibility = - if (exposureTimePreferred) View.VISIBLE else View.INVISIBLE - if ( /* - * binding.staticExposureTimeSlider.getVisibility() != expectedExposureTimeVisibility - * || - */binding.staticExposureTime.visibility != expectedExposureTimeVisibility || binding.staticExposureTimeUnit.getVisibility() != expectedExposureTimeVisibility || binding.staticExposureValueTitle.getVisibility() != expectedExposureTimeVisibility || binding.staticExposureValueSlider.getVisibility() != expectedExposureTimeVisibility || binding.staticExposureValue.getVisibility() != expectedExposureTimeVisibility || binding.staticExposureValueDescription.getVisibility() != expectedExposureTimeVisibility) { - Log.d( - TAG, "updateView -> exposure time fields to " - + if (expectedExposureTimeVisibility == View.VISIBLE) "visible" else "invisible" - ) - // binding.staticExposureTimeSlider.setVisibility(expectedExposureTimeVisibility); - binding.staticExposureTime.visibility = expectedExposureTimeVisibility - binding.staticExposureTimeUnit.visibility = expectedExposureTimeVisibility - binding.staticExposureValueTitle.visibility = expectedExposureTimeVisibility - binding.staticExposureValueSlider.visibility = expectedExposureTimeVisibility - binding.staticExposureValue.visibility = expectedExposureTimeVisibility - binding.staticExposureValueDescription.visibility = expectedExposureTimeVisibility - } - } - - /** - * Displays the supported camera2 features in the view. - * - * @param characteristics the features to check - * @param isManualSensorSupported `True` if the 'manual sensor' feature is supported - * @param minFocusDistance the minimum focus distance supported - */ - private fun displaySupportLevelsAndUnits( - characteristics: CameraCharacteristics, - isManualSensorSupported: Boolean, minFocusDistance: Float - ) { - - // Display camera2 API support level - val camera2Level = getCamera2SupportLevel(characteristics) - binding.hardwareSupport.text = camera2Level - - // Display 'manual sensor' support - binding.manualSensorSupport.text = - if (isManualSensorSupported) "supported" else "no supported" - - // Display whether the focus distance setting is calibrated (i.e. has a unit) - val calibrationLevel = getFocusDistanceCalibration(characteristics) - val unitDetails = - if (calibrationLevel == FOCUS_DISTANCE_NOT_CALIBRATED) "uncalibrated" else "dioptre" - val unit = - if (calibrationLevel == FOCUS_DISTANCE_NOT_CALIBRATED) "" else " [dioptre]" - binding.focusDistance.text = calibrationLevel - binding.staticFocusUnit.setText(unitDetails) - - // Display focus distance ranges - val hyperFocalDistance = - characteristics.get(CameraCharacteristics.LENS_INFO_HYPERFOCAL_DISTANCE) - val hyperFocalDistanceText = hyperFocalDistance.toString() + unit - val shortestFocalDistanceText = minFocusDistance.toString() + unit - binding.hyperFocalDistance.text = hyperFocalDistanceText - binding.minimumFocusDistance.text = shortestFocalDistanceText - } - - /** - * Returns the hardware calibration of the focus distance feature as simple `String`. - * - * - * If "uncalibrated" the focus distance does not have a unit, else it's in dioptre [1/m], more details: - * https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics#LENS_INFO_FOCUS_DISTANCE_CALIBRATION - * - * @param characteristics the hardware feature set to check - * @return the calibration level as `String` - */ - private fun getFocusDistanceCalibration(characteristics: CameraCharacteristics): String { - val focusDistanceCalibration = characteristics - .get(CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION) ?: return "N/A" - return when (focusDistanceCalibration) { - CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED -> "calibrated" - CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE -> "approximate" - CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_UNCALIBRATED -> FOCUS_DISTANCE_NOT_CALIBRATED - else -> "unknown: $focusDistanceCalibration" - } - } - - /** - * Returns the support level for the 'camera2' API, see http://stackoverflow.com/a/31240881/5815054. - * - * @param characteristics the camera hardware features to check - * @return the support level as simple `String` - */ - private fun getCamera2SupportLevel(characteristics: CameraCharacteristics): String { - val supportedHardwareLevel = characteristics - .get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) - var supportLevel = "unknown" - if (supportedHardwareLevel != null) { - when (supportedHardwareLevel) { - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY -> supportLevel = "legacy" - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED -> supportLevel = "limited" - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL -> supportLevel = "full" - } - } - return supportLevel - }*/ - - companion object { - /** - * The tag used to identify logging from this class. - */ - const val TAG = Constants.PACKAGE + ".sf" - - /** - * The identifier for the [ExposureTimeDialog] request. - */ - const val DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE = 202002170 - - /** - * Exposure value from tabulates values (for iso 100) for outdoor environment light settings - * see https://en.wikipedia.org/wiki/Exposure_value#Tabulated_exposure_values - * / - val EXPOSURE_VALUES: TreeMap = object : TreeMap() { - init { - put(10, "twilight") - put(11, "twilight") - put(12, "deep shade") - put(13, "cloudy, no shadows") - put(14, "partly cloudy, soft shadows") - put(15, "full sunlight") - put(16, "sunny, snowy/sandy") - } - }*/ - } -} - -/** - * Handles when the user toggles the upload switch. - * - * @author Armin Schnabel - * @version 1.0.0 - * @since 3.2.0 - */ -class UploadSwitchHandler( - private val preferences: SharedPreferences, - private val context: Context?, - private val capturingService: CyfaceDataCapturingService -) : CompoundButton.OnCheckedChangeListener { - override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { - val current = preferences.getBoolean(PREFERENCES_SYNCHRONIZATION_KEY, true) - if (current != isChecked) { - // Update both, the preferences and WifiSurveyor's synchronizationEnabled status - capturingService.wiFiSurveyor.isSyncEnabled = isChecked - preferences.edit().putBoolean(PREFERENCES_SYNCHRONIZATION_KEY, isChecked).apply() - - // Show warning to user (storage gets filled) - if (!isChecked) { - Toast.makeText( - context, - de.cyface.app.utils.R.string.sync_disabled_toast, - Toast.LENGTH_LONG - ).show() - } - } - } -} - -/** - * Handles when the user toggles the center map switch. - * - * @author Armin Schnabel - * @version 1.0.0 - * @since 3.2.0 - */ -private class CenterMapSwitchHandler( - private val preferences: SharedPreferences, - private val context: Context? -) : CompoundButton.OnCheckedChangeListener { - override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { - val current = preferences.getBoolean(PREFERENCES_CENTER_MAP_KEY, true) - if (current != isChecked) { - preferences.edit().putBoolean(PREFERENCES_CENTER_MAP_KEY, isChecked).apply() - if (isChecked) { - Toast.makeText( - context, - de.cyface.app.utils.R.string.zoom_to_location_enabled_toast, - Toast.LENGTH_LONG - ).show() - } - } - } -} - -/** - * Handles UI changes of the 'slider' used to adjust the 'focus distance' setting. - * / -private class StaticFocusDistanceSliderHandler( -private val preferences: SharedPreferences, -private val staticFocusDistance: TextView -) : Slider.OnChangeListener { -override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { -val roundedDistance = (newValue * 100).roundToInt() / 100f -Log.d( -TAG, -"Update preference to focus distance -> $roundedDistance" -) -val preference = preferences.getFloat( -de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_DISTANCE_KEY, -de.cyface.camera_service.Constants.DEFAULT_STATIC_FOCUS_DISTANCE -) -if (preference == roundedDistance) { -Log.d( -TAG, -"Preference already $preference, doing nothing" -) -return -} -val editor: Editor = preferences.edit() -editor.putFloat( -de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_DISTANCE_KEY, -roundedDistance -).apply() -val text = StringBuilder(roundedDistance.toString()) -while (text.length < 4) { -text.append("0") -} -staticFocusDistance.text = text -} -} - -/** - * Handles UI changes of the 'slider' used to adjust the 'triggering distance' setting. -*/ -private class TriggerDistanceSliderHandler( -private val preferences: SharedPreferences, -private val distanceBased: TextView -) : Slider.OnChangeListener { -override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { -val roundedDistance = (newValue * 100).roundToInt() / 100f -Log.d( -TAG, -"Update preference to triggering distance -> $roundedDistance" -) -val preference: Float = preferences.getFloat( -de.cyface.camera_service.Constants.PREFERENCES_CAMERA_TRIGGERING_DISTANCE_KEY, -de.cyface.camera_service.Constants.DEFAULT_TRIGGERING_DISTANCE -) -if (preference == roundedDistance) { -Log.d( -TAG, -"Preference already $preference, doing nothing" -) -return -} -val editor: Editor = preferences.edit() -editor.putFloat( -de.cyface.camera_service.Constants.PREFERENCES_CAMERA_TRIGGERING_DISTANCE_KEY, -roundedDistance -).apply() -val text = StringBuilder(roundedDistance.toString()) -while (text.length < 4) { -text.append("0") -} -distanceBased.text = text -} -} - -/** - * Handles UI changes of the 'switcher' used to en-/disable 'static focus' feature. -*/ -private class StaticFocusSwitcherHandler( -private val context: Context, -private val preferences: SharedPreferences, -private val manualSensorSupported: Boolean, -private val staticFocusSwitcher: SwitchCompat, -private val staticFocusDistanceSlider: Slider, -private val staticFocusDistance: TextView, -private val staticFocusUnit: TextView -) : CompoundButton.OnCheckedChangeListener { -override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { -if (!manualSensorSupported && isChecked) { -Toast.makeText( -context, -"This device does not support manual focus control", -Toast.LENGTH_LONG -) -.show() -staticFocusSwitcher.isChecked = false -return -} -Log.d(TAG, "Update preference to focus -> " + if (isChecked) "manual" else "auto") -val preference = preferences.getBoolean( -de.cyface.camera_service.Constants.PREFERENCES_CAMERA_STATIC_FOCUS_ENABLED_KEY, -false -) -if (preference == isChecked) { -Log.d(TAG, "Preference already $preference, doing nothing") -return -} -val editor = preferences.edit() -editor.putBoolean(PREFERENCES_CAMERA_STATIC_FOCUS_ENABLED_KEY, isChecked).apply() -if (isChecked) { -Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG) -.show() -} -// Update visibility of slider -val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE -val sliderVisibilityOutOfSync = staticFocusDistanceSlider.visibility != expectedVisibility -val preferenceVisibilityOutOfSync = staticFocusDistance.visibility != expectedVisibility -val unitVisibilityOutOfSync = staticFocusUnit.visibility != expectedVisibility -if (sliderVisibilityOutOfSync || preferenceVisibilityOutOfSync || unitVisibilityOutOfSync) { -Log.d( -TAG, -"updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" -) -staticFocusDistanceSlider.visibility = expectedVisibility -staticFocusDistance.visibility = expectedVisibility -staticFocusUnit.visibility = expectedVisibility -} -} -} - -/** - * Handles UI changes of the 'switcher' used to en-/disable 'distance based triggering' feature. -*/ -private class DistanceBasedSwitcherHandler( -private val context: Context, -private val preferences: SharedPreferences, -private val distanceBasedSlider: Slider, -private val distanceBased: TextView, -private val distanceBasedUnit: TextView -) : CompoundButton.OnCheckedChangeListener { -override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { -Log.d(TAG, "Update preference to distance-based-trigger -> $isChecked") -val preference = preferences.getBoolean( -PREFERENCES_CAMERA_DISTANCE_BASED_TRIGGERING_ENABLED_KEY, -true -) -if (preference == isChecked) { -Log.d(TAG, "Preference already $preference, doing nothing") -return -} -val editor = preferences.edit() -editor.putBoolean(PREFERENCES_CAMERA_DISTANCE_BASED_TRIGGERING_ENABLED_KEY, isChecked) -.apply() -if (isChecked) { -Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG).show() -} -// Update visibility of slider -val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE -val sliderVisibilityOutOfSync = distanceBasedSlider.visibility != expectedVisibility -val preferenceVisibilityOutOfSync = distanceBased.visibility != expectedVisibility -val unitVisibilityOutOfSync = distanceBasedUnit.visibility != expectedVisibility -if (sliderVisibilityOutOfSync || preferenceVisibilityOutOfSync || unitVisibilityOutOfSync) { -Log.d( -TAG, -"updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" -) -distanceBasedSlider.visibility = expectedVisibility -distanceBased.visibility = expectedVisibility -distanceBasedUnit.visibility = expectedVisibility -} -} -} - -/** - * Handles UI clicks on the exposure time used to adjust the 'exposure time' setting. -*/ -private class StaticExposureTimeClickHandler( -private val fragmentManager: FragmentManager?, -private val settingsFragment: SettingsFragment -) : View.OnClickListener { -override fun onClick(v: View) { -Log.d( -TAG, -"StaticExposureTimeClickHandler triggered, showing ExposureTimeDialog" -) -Validate.notNull(fragmentManager) -val dialog = ExposureTimeDialog() -dialog.setTargetFragment( -settingsFragment, -DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE -) -dialog.isCancelable = true -dialog.show(fragmentManager!!, "EXPOSURE_TIME_DIALOG") -} -} - -/* - * Handles UI changes of the 'slider' used to adjust the 'exposure time' setting. - * / - * private class StaticExposureTimeSliderHandler implements Slider.OnChangeListener { - * - * @Override - * public void onValueChange(Slider slider, float newValue) { - * Log.d(TAG, "Update preference to exposure time -> " + newValue); - * - * final long preferenceNanos = Math.round(preferences.getLong(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, - * DEFAULT_STATIC_EXPOSURE_TIME)); - * if (preferenceNanos == newValue) { - * Log.d(TAG, "Preference already " + preferenceNanos + " ns, doing nothing"); - * return; - * } - * - * final long value = (long)newValue; - * final SharedPreferences.Editor editor = preferences.edit(); - * editor.putLong(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, value).apply(); - * - * staticExposureTimePreference.setText(getExposureTimeFraction(value)); - * } - * } -*/ - -/* - * Handles UI changes of the 'slider' used to adjust the 'exposure time' setting. - * / - * private class StaticExposureTimeSliderHandler implements Slider.OnChangeListener { - * - * @Override - * public void onValueChange(Slider slider, float newValue) { - * Log.d(TAG, "Update preference to exposure time -> " + newValue); - * - * final long preferenceNanos = Math.round(preferences.getLong(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, - * DEFAULT_STATIC_EXPOSURE_TIME)); - * if (preferenceNanos == newValue) { - * Log.d(TAG, "Preference already " + preferenceNanos + " ns, doing nothing"); - * return; - * } - * - * final long value = (long)newValue; - * final SharedPreferences.Editor editor = preferences.edit(); - * editor.putLong(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, value).apply(); - * - * staticExposureTimePreference.setText(getExposureTimeFraction(value)); - * } - * } -*/ - -/** - * Handles UI changes of the 'switcher' used to en-/disable 'static exposure time' feature. -*/ -private class StaticExposureTimeSwitcherHandler( -private val preferences: SharedPreferences, -private val context: Context, -private val manualSensorSupported: Boolean, -private val staticExposureTimeSwitcher: SwitchCompat, -private val staticExposureTime: TextView, -private val staticExposureTimeUnit: TextView, -private val staticExposureValueTitle: TextView, -private val staticExposureValueSlider: Slider, -private val staticExposureValue: TextView, -private val staticExposureValueDescription: TextView -) : CompoundButton.OnCheckedChangeListener { -override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { -if (!manualSensorSupported && isChecked) { -Toast.makeText( -context, -"This device does not support manual exposure control", -Toast.LENGTH_LONG -).show() -staticExposureTimeSwitcher.isChecked = false -return -} -Log.d(TAG, "Update preference to exposure -> " + if (isChecked) "Tv-/S-Mode" else "auto") -val preference = -preferences.getBoolean(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_ENABLED_KEY, false) -if (preference == isChecked) { -Log.d(TAG, "Preference already $preference, doing nothing") -return -} -val editor = preferences.edit() -editor.putBoolean(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_ENABLED_KEY, isChecked).apply() -if (isChecked) { -Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG).show() -} -// Update visibility of slider -val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE -// final boolean sliderVisibilityOutOfSync = staticExposureTimeSlider.getVisibility() != expectedVisibility; -val preferenceVisibilityOutOfSync = staticExposureTime.visibility != expectedVisibility -val unitVisibilityOutOfSync = staticExposureTimeUnit.visibility != expectedVisibility -val evTitleVisibilityOutOfSync = staticExposureValueTitle.visibility != expectedVisibility -val evSliderVisibilityOutOfSync = staticExposureValueSlider.visibility != expectedVisibility -val evPreferenceVisibilityOutOfSync = staticExposureValue.visibility != expectedVisibility -val evDescriptionVisibilityOutOfSync = -staticExposureValueDescription.visibility != expectedVisibility -if ( /* sliderVisibilityOutOfSync || */preferenceVisibilityOutOfSync || unitVisibilityOutOfSync -|| evTitleVisibilityOutOfSync || evSliderVisibilityOutOfSync || evPreferenceVisibilityOutOfSync -|| evDescriptionVisibilityOutOfSync -) { -Log.d( -TAG, -"updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" -) -// staticExposureTimeSlider.setVisibility(expectedVisibility); -staticExposureTime.visibility = expectedVisibility -staticExposureTimeUnit.visibility = expectedVisibility -staticExposureValueTitle.visibility = expectedVisibility -staticExposureValueSlider.visibility = expectedVisibility -staticExposureValue.visibility = expectedVisibility -staticExposureValueDescription.visibility = expectedVisibility -} -} -} - -/** - * Handles UI changes of the 'slider' used to adjust the 'exposure value' setting. -*/ -private class StaticExposureValueSliderHandler( -private val preferences: SharedPreferences, -private val staticExposureValue: TextView, -private val staticExposureValueDescription: TextView -) : Slider.OnChangeListener { -override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { -Log.d( -TAG, -"Update preference to exposure value -> $newValue" -) -val preference = preferences.getInt( -PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_EXPOSURE_VALUE_KEY, -DEFAULT_STATIC_EXPOSURE_VALUE_ISO_100 -) -if (preference.toFloat() == newValue) { -Log.d(TAG, "Preference already $preference, doing nothing") -return -} -val value = newValue.toInt() -val description = EXPOSURE_VALUES[value] -val editor = preferences.edit() -editor.putInt(PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_EXPOSURE_VALUE_KEY, value).apply() -staticExposureValue.text = value.toString() -staticExposureValueDescription.text = description -} -} - -/** - * Handles UI changes of the 'slider' used to adjust the 'sensor frequency' setting. -*/ -private class SensorFrequencySliderHandler( -private val preferences: SharedPreferences, -private val sensorFrequency: TextView -) : Slider.OnChangeListener { -override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { -Log.d(TAG, "Update preference to sensor frequency -> $newValue") -val newSensorFrequency = newValue.toInt() -val preference = -preferences.getInt(PREFERENCES_SENSOR_FREQUENCY_KEY, DEFAULT_SENSOR_FREQUENCY) -if (preference == newSensorFrequency) { -Log.d(TAG, "Preference already $preference, doing nothing") -return -} -val editor = preferences.edit() -editor.putInt(PREFERENCES_SENSOR_FREQUENCY_KEY, newSensorFrequency).apply() -val text = StringBuilder(newSensorFrequency) -sensorFrequency.text = text -} -} - -/** - * A listener which is called when the camera switch is clicked. - * - * @author Armin Schnabel - * @version 1.0.0 - * @since 2.0.0 - * / -private class PicturesToggleListener : CompoundButton.OnCheckedChangeListener { -override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { -val applicationContext: Context = view.getContext().getApplicationContext() -val editor: Editor = preferences.edit() - -// Disable camera -if (!buttonView.isChecked) { -editor.putBoolean( -PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, -false -).apply() -return -} - -// No rear camera found to be enabled - we explicitly only support rear camera for now -@SuppressLint("UnsupportedChromeOsCameraSystemFeature") val noCameraFound = -!applicationContext.packageManager -.hasSystemFeature(PackageManager.FEATURE_CAMERA) -if (noCameraFound) { -buttonView.isChecked = false -Toast.makeText( -applicationContext, -R.string.no_camera_available_toast, -Toast.LENGTH_LONG -).show() -return -} - -// Request permission for camera capturing -val permissionsGranted = ActivityCompat.checkSelfPermission( -applicationContext, -Manifest.permission.CAMERA -) == PackageManager.PERMISSION_GRANTED -/* TODO: if (!permissionsGranted) { -ActivityCompat.requestPermissions(mainActivity, -new String[] {Manifest.permission.CAMERA}, -PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION); -} else { -// Ask user to select camera mode -mainActivity.showCameraModeDialog(); -}*/ - -// Enable camera capturing feature -editor.putBoolean( -PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, -true -).apply() -} -}*/ - -/** - * Called when an exposure time was selected in the [ExposureTimeDialog]. - * - * @param data an intent which may contain result data - * / -fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { -super.onActivityResult(requestCode, resultCode, data) -if (requestCode == DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE) { -val exposureTimeNanos = data.getLongExtra( -PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, --1L -) -Validate.isTrue(exposureTimeNanos != -1L) -val fraction = Utils.getExposureTimeFraction(exposureTimeNanos) -Log.d( -TAG, -"Update view to exposure time -> $exposureTimeNanos ns - fraction: $fraction s" -) -staticExposureTime.setText(fraction) -} -}* / - -/* - * override fun onRequestPermissionsResult( - * requestCode: Int, permissions: Array, - * grantResults: IntArray - * ) { - * // noinspection SwitchStatementWithTooFewBranches - * when (requestCode) { - * PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION -> { - * val granted = grantResults[0] == PackageManager.PERMISSION_GRANTED - * val unexpectedPermissionNumber = grantResults.size < 2 - * val missingPermissions = - * !(granted && (unexpectedPermissionNumber || (grantResults[1] == PackageManager.PERMISSION_GRANTED))) - * - * if (missingPermissions) { - * // Deactivate camera service and inform user about this - * //navDrawer!!.deactivateCameraService() - * Toast.makeText( - * applicationContext, - * applicationContext.getString( - * de.cyface.camera_service.R.string.camera_service_off_missing_permissions - * ), - * Toast.LENGTH_LONG - * ).show() - * } else { - * // Ask used which camera mode to use, video or default (shutter image) - * showCameraModeDialog() - * } - * } - * else -> { - * super.onRequestPermissionsResult(requestCode, permissions, grantResults) - * } - * } - * } - * - * // final SwitchCompat connectToExternalSpeedSensorToggle = (SwitchCompat)view.getMenu() - * // .findItem(R.id.drawer_setting_speed_sensor).getActionView(); - * cameraServiceToggle = (SwitchCompat)view.getMenu().findItem(R.id.drawer_setting_pictures).getActionView(); - * - * /* - * final boolean bluetoothIsConfigured = preferences.getString(BLUETOOTHLE_DEVICE_MAC_KEY, null) != null - * && preferences.getFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, 0.0F) > 0.0F; - * connectToExternalSpeedSensorToggle.setChecked(bluetoothIsConfigured); - * / - * cameraServiceToggle.setChecked(preferences.getBoolean(PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, false)); - * - * // connectToExternalSpeedSensorToggle.setOnClickListener(new ConnectToExternalSpeedSensorToggleListener()); - * cameraServiceToggle.setOnCheckedChangeListener(new PicturesToggleListener()); -public void deactivateCameraService() { -cameraServiceToggle.setChecked(false); -final SharedPreferences.Editor editor = preferences.edit(); -editor.putBoolean(PREFERENCES_CAMERA_CAPTURING_ENABLED_KEY, false); -editor.apply(); -} - -private void cameraSettingsSelected(final MenuItem item) { -for (NavDrawerListener listener : this.listener) { -listener.cameraSettingsSelected(); -} -finishSelection(item); -} - -/* - * A listener which is called when the external bluetooth sensor toggle in the {@link NavDrawer} is clicked. - * / - * private class ConnectToExternalSpeedSensorToggleListener implements CompoundButton.OnCheckedChangeListener { - * - * @Override - * public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - * final CompoundButton compoundButton = (CompoundButton)view; - * final Context applicationContext = view.getContext().getApplicationContext(); - * if (compoundButton.isChecked()) { - * final BluetoothLeSetup bluetoothLeSetup = new BluetoothLeSetup(new BluetoothLeSetupListener() { - * - * @Override - * public void onDeviceSelected(final BluetoothDevice device, final double wheelCircumference) { - * final SharedPreferences.Editor editor = preferences.edit(); - * editor.putString(BLUETOOTHLE_DEVICE_MAC_KEY, device.getAddress()); - * editor.putFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, - * Double.valueOf(wheelCircumference).floatValue()); - * editor.apply(); - * } - * - * @Override - * public void onSetupProcessFailed(final Reason reason) { - * compoundButton.setChecked(false); - * if (reason.equals(Reason.NOT_SUPPORTED)) { - * Toast.makeText(applicationContext, R.string.ble_not_supported, Toast.LENGTH_SHORT) - * .show(); - * } else { - * Log.e(TAG, "Setup process of bluetooth failed: " + reason); - * Toast.makeText(applicationContext, R.string.bluetooth_setup_failed, Toast.LENGTH_SHORT) - * .show(); - * } - * } - * }); - * bluetoothLeSetup.setup(mainActivity); - * } else { - * final SharedPreferences.Editor editor = preferences.edit(); - * editor.remove(BLUETOOTHLE_DEVICE_MAC_KEY); - * editor.remove(BLUETOOTHLE_WHEEL_CIRCUMFERENCE); - * editor.apply(); - * } - * } - * } - -/** - * Displays a dialog for the user to select a camera mode (video- or picture mode). - * - * / -fun showCameraModeDialog() { - -// avoid crash DAT-69 -/*homeSelected() -val cameraModeDialog: DialogFragment = CameraModeDialog() -cameraModeDialog.setTargetFragment( -capturingFragment, -de.cyface.energy_settings.Constants.DIALOG_ENERGY_SAFER_WARNING_CODE -) -cameraModeDialog.isCancelable = false -cameraModeDialog.show(fragmentManager!!, "CAMERA_MODE_DIALOG")* / -}*/ - -/*override fun onRequestPermissionsResult( -requestCode: Int, permissions: Array, grantResults: IntArray -) { -@Suppress("UNUSED_EXPRESSION") -when (requestCode) { -// Location permission request moved to `MapFragment` as it has to react to results - -/*PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION -> if (navDrawer != null && !(grantResults[0] == PackageManager.PERMISSION_GRANTED -&& (grantResults.size < 2 || grantResults[1] == PackageManager.PERMISSION_GRANTED)) -) { -// Deactivate camera service and inform user about this -navDrawer.deactivateCameraService() -Toast.makeText( -applicationContext, -applicationContext.getString(de.cyface.camera_service.R.string.camera_service_off_missing_permissions), -Toast.LENGTH_LONG -).show() -} else { -// Ask used which camera mode to use, video or default (shutter image) -showCameraModeDialog() -}* / -else -> { -super.onRequestPermissionsResult(requestCode, permissions, grantResults) -} -} -}*/ -*/ - - */ \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/CenterMapSwitchHandler.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/CenterMapSwitchHandler.kt new file mode 100644 index 00000000..d884e514 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/CenterMapSwitchHandler.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.capturing.settings + +import android.content.Context +import android.widget.CompoundButton +import android.widget.Toast +import de.cyface.app.utils.R + +/** + * Handles when the user toggles the center map switch. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.2.0 + */ +class CenterMapSwitchHandler( + private val viewModel: SettingsViewModel, + private val context: Context? +) : CompoundButton.OnCheckedChangeListener { + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + if (viewModel.centerMap.value != isChecked) { + viewModel.setCenterMap(isChecked) + if (isChecked) { + Toast.makeText( + context, + R.string.zoom_to_location_enabled_toast, + Toast.LENGTH_LONG + ).show() + } + } + } +} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsFragment.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsFragment.kt new file mode 100644 index 00000000..ed294851 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsFragment.kt @@ -0,0 +1,177 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.capturing.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import de.cyface.app.databinding.FragmentSettingsBinding +import de.cyface.app.utils.ServiceProvider +import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.utils.AppPreferences + +/** + * The [Fragment] which shows the settings to the user. + * + * @author Armin Schnabel + * @version 2.0.0 + * @since 3.2.0 + */ +class SettingsFragment : Fragment() { + + /** + * This property is only valid between onCreateView and onDestroyView. + */ + private var _binding: FragmentSettingsBinding? = null + + /** + * The generated class which holds all bindings from the layout file. + */ + private val binding get() = _binding!! + + /** + * The capturing service object which controls data capturing and synchronization. + */ + private lateinit var capturing: CyfaceDataCapturingService + + /** + * The [SettingsViewModel] for this fragment. + */ + private lateinit var viewModel: SettingsViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Initialize ViewModel + val appPreferences = AppPreferences(requireContext().applicationContext) + viewModel = ViewModelProvider( + this, + SettingsViewModelFactory(appPreferences) + )[SettingsViewModel::class.java] + + // Initialize CapturingService + if (activity is ServiceProvider) { + capturing = (activity as ServiceProvider).capturing + } else { + throw RuntimeException("Context does not support the Fragment, implement ServiceProvider") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + + // Register onClick listeners + binding.centerMapSwitch.setOnCheckedChangeListener( + CenterMapSwitchHandler( + viewModel, + context + ) + ) + binding.uploadSwitch.setOnCheckedChangeListener( + UploadSwitchHandler( + viewModel, + context, + capturing + ) + ) + + // Observe view model and update UI + viewModel.centerMap.observe(viewLifecycleOwner) { centerMapValue -> + run { + binding.centerMapSwitch.isChecked = centerMapValue!! + } + } + viewModel.upload.observe(viewLifecycleOwner) { uploadValue -> + run { + binding.uploadSwitch.isChecked = uploadValue!! + } + } + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + + +// final SwitchCompat connectToExternalSpeedSensorToggle = (SwitchCompat)view.getMenu() +// .findItem(R.id.drawer_setting_speed_sensor).getActionView(); + +/* +final boolean bluetoothIsConfigured = preferences.getString(BLUETOOTHLE_DEVICE_MAC_KEY, null) != null +&& preferences.getFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, 0.0F) > 0.0F; +connectToExternalSpeedSensorToggle.setChecked(bluetoothIsConfigured); + +// connectToExternalSpeedSensorToggle.setOnClickListener(new ConnectToExternalSpeedSensorToggleListener()); + + +/* + * A listener which is called when the external bluetooth sensor toggle in the {@link NavDrawer} is clicked. + * / + * private class ConnectToExternalSpeedSensorToggleListener implements CompoundButton.OnCheckedChangeListener { + * + * @Override + * public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + * final CompoundButton compoundButton = (CompoundButton)view; + * final Context applicationContext = view.getContext().getApplicationContext(); + * if (compoundButton.isChecked()) { + * final BluetoothLeSetup bluetoothLeSetup = new BluetoothLeSetup(new BluetoothLeSetupListener() { + * + * @Override + * public void onDeviceSelected(final BluetoothDevice device, final double wheelCircumference) { + * final SharedPreferences.Editor editor = preferences.edit(); + * editor.putString(BLUETOOTHLE_DEVICE_MAC_KEY, device.getAddress()); + * editor.putFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, + * Double.valueOf(wheelCircumference).floatValue()); + * editor.apply(); + * } + * + * @Override + * public void onSetupProcessFailed(final Reason reason) { + * compoundButton.setChecked(false); + * if (reason.equals(Reason.NOT_SUPPORTED)) { + * Toast.makeText(applicationContext, R.string.ble_not_supported, Toast.LENGTH_SHORT) + * .show(); + * } else { + * Log.e(TAG, "Setup process of bluetooth failed: " + reason); + * Toast.makeText(applicationContext, R.string.bluetooth_setup_failed, Toast.LENGTH_SHORT) + * .show(); + * } + * } + * }); + * bluetoothLeSetup.setup(mainActivity); + * } else { + * final SharedPreferences.Editor editor = preferences.edit(); + * editor.remove(BLUETOOTHLE_DEVICE_MAC_KEY); + * editor.remove(BLUETOOTHLE_WHEEL_CIRCUMFERENCE); + * editor.apply(); + * } + * } + * } + */ \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModel.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModel.kt new file mode 100644 index 00000000..3141cd90 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.capturing.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import de.cyface.utils.AppPreferences + +/** + * This is the [ViewModel] for the [SettingsFragment]. + * + * It holds the UI data/state for that UI element in a lifecycle-aware way, surviving configuration changes. + * + * It acts as a communicator between the data layer's `Repository` and the UI layer's UI elements. + * + * *Attention*: + * - Don't keep references to a `Context` that has a shorter lifecycle than the [ViewModel]. + * https://developer.android.com/codelabs/android-room-with-a-view-kotlin#9 + * - [ViewModel]s don't survive when the app's process is killed in the background. + * UI data which needs to survive this, use "Saved State module for ViewModels": + * https://developer.android.com/topic/libraries/architecture/viewmodel-savedstate + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.4.0 + */ +class SettingsViewModel(private val appPreferences: AppPreferences) : ViewModel() { + + private val _centerMap = MutableLiveData() + private val _upload = MutableLiveData() + + init { + _centerMap.value = appPreferences.getCenterMap() + _upload.value = appPreferences.getUpload() + } + + val centerMap: LiveData = _centerMap + val upload: LiveData = _upload + + fun setCenterMap(centerMap: Boolean) { + appPreferences.saveCenterMap(centerMap) + _centerMap.postValue(centerMap) + } + + fun setUpload(upload: Boolean) { + appPreferences.saveUpload(upload) + _upload.postValue(upload) + } +} + diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModelFactory.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModelFactory.kt new file mode 100644 index 00000000..39d6a963 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/SettingsViewModelFactory.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.capturing.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import de.cyface.utils.AppPreferences + +/** + * Factory which creates the [ViewModel] with the required dependencies. + * + * Survives configuration changes and returns the right instance after Activity recreation. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.4.0 + */ +class SettingsViewModelFactory(private val appPreferences: AppPreferences) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return SettingsViewModel(appPreferences) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/UploadSwitchHandler.kt b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/UploadSwitchHandler.kt new file mode 100644 index 00000000..8b89c823 --- /dev/null +++ b/ui/cyface/src/main/kotlin/de/cyface/app/capturing/settings/UploadSwitchHandler.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.capturing.settings + +import android.content.Context +import android.widget.CompoundButton +import android.widget.Toast +import de.cyface.app.utils.R +import de.cyface.datacapturing.CyfaceDataCapturingService + +/** + * Handles when the user toggles the upload switch. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.2.0 + */ +class UploadSwitchHandler( + private val viewModel: SettingsViewModel, + private val context: Context?, + private val capturingService: CyfaceDataCapturingService +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + if (viewModel.upload.value != isChecked) { + // Also update WifiSurveyor's synchronizationEnabled + capturingService.wiFiSurveyor.isSyncEnabled = isChecked + viewModel.setUpload(isChecked) + + // Show warning to user (storage gets filled) + if (!isChecked) { + Toast.makeText( + context, + R.string.sync_disabled_toast, + Toast.LENGTH_LONG + ).show() + } + } + } +} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/controller/MeasurementDeleteController.java b/ui/cyface/src/main/kotlin/de/cyface/app/controller/MeasurementDeleteController.java deleted file mode 100644 index 61f9a7e6..00000000 --- a/ui/cyface/src/main/kotlin/de/cyface/app/controller/MeasurementDeleteController.java +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2017-2023 Cyface GmbH - * - * This file is part of the Cyface App for Android. - * - * The Cyface App for Android is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * The Cyface App for Android is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with the Cyface App for Android. If not, see . - */ -package de.cyface.app.controller; - -import static de.cyface.app.utils.SharedConstants.ACCEPTED_REPORTING_KEY; -import static de.cyface.app.utils.Constants.AUTHORITY; -import static de.cyface.app.utils.Constants.TAG; -import static de.cyface.camera_service.Constants.externalCyfaceFolderPath; -import static de.cyface.utils.Utils.informMediaScanner; - -import java.io.File; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import android.content.Context; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.os.AsyncTask; -import android.preference.PreferenceManager; -import android.util.Log; -import android.util.SparseBooleanArray; -import android.widget.ListView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import de.cyface.app.R; -import de.cyface.persistence.DefaultPersistenceBehaviour; -import de.cyface.persistence.DefaultPersistenceLayer; -import de.cyface.persistence.content.BaseColumns; -import de.cyface.persistence.exception.NoSuchMeasurementException; -import de.cyface.persistence.model.Measurement; -import de.cyface.utils.Validate; -import io.sentry.Sentry; - -/** - * Async task to delete measurements with all their data. - * We use an AsyncTask because this is blocking but should only run for a short time. - * - * TODO: This needs to move into Trips MenuProvider delete action after camera is re-added. - * - * @author Armin Schnabel - * @author Klemens Muthmann - * @version 2.0.4 - * @since 1.0.0 - */ -public final class MeasurementDeleteController extends AsyncTask { - - private final WeakReference contextReference; - /** - * The data persistenceLayer used by this controller. - */ - private final DefaultPersistenceLayer persistenceLayer; - /** - * {@code True} if the user opted-in to error reporting. - */ - private final boolean isReportingEnabled; - - /** - * Creates a new completely initialized object of this class with the current context. - */ - public MeasurementDeleteController(@NonNull final Context context) { - this.contextReference = new WeakReference<>(context); - this.persistenceLayer = new DefaultPersistenceLayer<>(context, new DefaultPersistenceBehaviour()); - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); - isReportingEnabled = preferences.getBoolean(ACCEPTED_REPORTING_KEY, false); - } - - @Override - protected ListView doInBackground(@NonNull final ListView... params) { - - final ListView view = params[0]; - - final List selectedMeasurements = getSelectedMeasurements(view); - // also delete the folder with the measurement pictures if it exists - // use getContext only on high APIs - for (final Measurement measurement : selectedMeasurements) { - final Context context = contextReference.get(); - Validate.notNull(context); - final File attachmentsFolder = findMeasurementAttachmentsFolder(measurement); - if (attachmentsFolder != null) { - deleteRecursively(context, attachmentsFolder); - } - persistenceLayer.delete(measurement.getId()); - } - return view; - } - - @Override - protected void onPostExecute(@NonNull final ListView listView) { - super.onPostExecute(listView); - // Fixes bug where multi selection mode can't left after deleting 2/3 entries - Log.d(TAG, "onPostExecute -> clearChoices & setChoiceMode to single"); - listView.clearChoices(); - listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); - Toast.makeText(contextReference.get(), de.cyface.app.utils.R.string.toast_measurement_deletion_success, Toast.LENGTH_LONG).show(); - } - - /** - * Delete all pictures captured by the Cyface SDK in Pro mode. - * - * @param context The current Android context used to access the file system. - * @param fileOrFolder The picture storage directory. - */ - private void deleteRecursively(@NonNull final Context context, @NonNull final File fileOrFolder) { - Log.d(TAG, "deleteRecursively: " + fileOrFolder.getPath()); - if (fileOrFolder.isDirectory()) { - final File[] files = fileOrFolder.listFiles(); - Validate.notNull(files); - for (final File child : files) { - deleteRecursively(context, child); - } - } - final boolean deleteSuccessful = fileOrFolder.delete(); - if (!deleteSuccessful) { - Log.w(TAG, "Delete was not successful: " + fileOrFolder.getAbsolutePath()); - return; - } - informMediaScanner(context, fileOrFolder); - } - - /** - * Searches for the folder containing pictures captured during the provided measurement, if any. - * - * @param measurement The measurement to search pictures for. - * @return Either the path to the request folder or null if there are no pictures. - */ - @Nullable - private File findMeasurementAttachmentsFolder(@NonNull final Measurement measurement) { - // If the app was reinstalled the pictures of the old installation were automatically deleted - final File[] results = new File(externalCyfaceFolderPath(contextReference.get())) - .listFiles(pathname -> pathname.getName().endsWith("_" + measurement.getId())); - if (results != null && results.length > 0) { - Arrays.sort(results); - return results[results.length - 1]; - } - return null; - } - - /** - * Finds the selected measurements inside the view. Ignores the ongoing measurement. - * - * @param view The view showing the measurements to delete. - * @return A list with all currently selected measurements in the ListView. - */ - @NonNull - private List getSelectedMeasurements(@NonNull final ListView view) { - final SparseBooleanArray checkedItemPositions = view.getCheckedItemPositions(); - final List ret = new ArrayList<>(); - - // Check if measurements are selected - if (checkedItemPositions.indexOfValue(true) == -1) { - return ret; - } - - // Load unfinished measurement - Measurement unFinishedMeasurement; - try { - unFinishedMeasurement = persistenceLayer.loadCurrentlyCapturedMeasurement(); - } catch (final NoSuchMeasurementException e) { - unFinishedMeasurement = null; - if (isReportingEnabled) { - Sentry.captureException(e); - } - } - - for (int itemPosition = 0; itemPosition < checkedItemPositions.size(); itemPosition++) { - if (!checkedItemPositions.valueAt(itemPosition)) { - Log.w(TAG, "No value for selected item, ignoring"); - continue; - } - final int checkedRowNumber = checkedItemPositions.keyAt(itemPosition); - // TODO [CY-4572]: final Measurement measurement = (Measurement)view.getItemAtPosition(checkedRowNumber); - final Cursor cursor = (Cursor)view.getItemAtPosition(checkedRowNumber); - final long selectedMeasurementId = cursor.getLong(cursor.getColumnIndexOrThrow(BaseColumns.ID)); - - // Ignoring the ongoing measurement - if (unFinishedMeasurement == null || selectedMeasurementId != unFinishedMeasurement.getId()) { - final var measurement = persistenceLayer.loadMeasurement(selectedMeasurementId); - ret.add(measurement); - } - } - return ret; - } -} diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ModalityDialog.kt b/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ModalityDialog.kt index 62c3ad42..4d392c8f 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ModalityDialog.kt +++ b/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ModalityDialog.kt @@ -23,13 +23,13 @@ import android.app.Dialog import android.content.DialogInterface import android.content.Intent import android.os.Bundle -import android.preference.PreferenceManager import androidx.fragment.app.DialogFragment import de.cyface.app.CapturingFragment import de.cyface.app.R -import de.cyface.app.utils.SharedConstants.PREFERENCES_MODALITY_KEY import de.cyface.persistence.model.Modality import de.cyface.synchronization.BundlesExtrasCodes +import de.cyface.utils.AppPreferences +import de.cyface.utils.AppPreferences.Companion.PREFERENCES_MODALITY_KEY import de.cyface.utils.Validate /** @@ -56,8 +56,7 @@ class ModalityDialog : DialogFragment() { ) { _: DialogInterface?, which: Int -> val fragmentActivity = activity Validate.notNull(fragmentActivity) - val editor = PreferenceManager - .getDefaultSharedPreferences(fragmentActivity!!.applicationContext).edit() + val preferences = AppPreferences(fragmentActivity!!.applicationContext) val modality: Modality = when (which) { 0 -> Modality.valueOf(Modality.CAR.name) 1 -> Modality.valueOf(Modality.BICYCLE.name) @@ -66,7 +65,7 @@ class ModalityDialog : DialogFragment() { 4 -> Modality.valueOf(Modality.TRAIN.name) else -> throw IllegalArgumentException("Unknown modality selected: $which") } - editor.putString(PREFERENCES_MODALITY_KEY, modality.databaseIdentifier).apply() + preferences.saveModality(modality.databaseIdentifier) val requestCode = targetRequestCode val resultCode: Int val intent = Intent() diff --git a/ui/cyface/src/main/res/layout/fragment_capturing.xml b/ui/cyface/src/main/res/layout/fragment_capturing.xml index c6ebc821..437f3a0c 100644 --- a/ui/cyface/src/main/res/layout/fragment_capturing.xml +++ b/ui/cyface/src/main/res/layout/fragment_capturing.xml @@ -195,7 +195,7 @@ android:layout_marginEnd="10dp" android:layout_marginBottom="10dp" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="@id/data_capturing_distance" + app:layout_constraintEnd_toStartOf="@id/data_capturing_distance" android:textColor="@color/text" android:textSize="14sp" /> diff --git a/ui/cyface/src/main/res/layout/fragment_settings.xml b/ui/cyface/src/main/res/layout/fragment_settings.xml index 9332b4a5..f40173b9 100644 --- a/ui/cyface/src/main/res/layout/fragment_settings.xml +++ b/ui/cyface/src/main/res/layout/fragment_settings.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - tools:context=".capturing.SettingsFragment"> + tools:context=".capturing.settings.SettingsFragment"> - - - - - - - - diff --git a/ui/cyface/src/main/res/navigation/nav_graph.xml b/ui/cyface/src/main/res/navigation/nav_graph.xml index a1e6ec92..c3c426e7 100644 --- a/ui/cyface/src/main/res/navigation/nav_graph.xml +++ b/ui/cyface/src/main/res/navigation/nav_graph.xml @@ -29,7 +29,7 @@ diff --git a/ui/cyface/src/main/res/values-de/strings.xml b/ui/cyface/src/main/res/values-de/strings.xml index a26f72d2..3356cbc8 100644 --- a/ui/cyface/src/main/res/values-de/strings.xml +++ b/ui/cyface/src/main/res/values-de/strings.xml @@ -18,9 +18,9 @@ Fahrt pausiert Fahrt fortgesetzt - + Externer Sensor - Kameramodus + Kameramodus Es wurde keine Kamera gefunden die benutzt werden kann Fehler beim Starten der Bluetooth Komponente Kamera Fokus Distanz diff --git a/ui/cyface/src/main/res/values-it/strings.xml b/ui/cyface/src/main/res/values-it/strings.xml index 228c46e8..96563b64 100644 --- a/ui/cyface/src/main/res/values-it/strings.xml +++ b/ui/cyface/src/main/res/values-it/strings.xml @@ -18,9 +18,9 @@ Viaggio in pausa Il viaggio riprende - + External sensor - Camera mode + Camera mode Sorry, no camera found to be used. Error starting the bluetooth component. Camera focus distance diff --git a/ui/cyface/src/main/res/values/strings.xml b/ui/cyface/src/main/res/values/strings.xml index bb8dfd80..cf95d53e 100644 --- a/ui/cyface/src/main/res/values/strings.xml +++ b/ui/cyface/src/main/res/values/strings.xml @@ -19,9 +19,9 @@ Trip paused Trip resumed - + External sensor - Camera mode + Camera mode Sorry, no camera found to be used. Error starting the bluetooth component. Camera focus distance diff --git a/ui/digural/build.gradle b/ui/digural/build.gradle new file mode 100644 index 00000000..80e7cb5f --- /dev/null +++ b/ui/digural/build.gradle @@ -0,0 +1,257 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +/** + * Gradle's build file for the app. + * + * @author Armin Schnabel + * @author Klemens Muthmann + * @version 1.11.0 + * @since 1.0.0 + */ +apply plugin: 'com.android.application' +apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' +apply plugin: 'io.sentry.android.gradle' // Exception tracking when using proguard +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'androidx.navigation.safeargs.kotlin' // recommended to navigate between fragments + +buildscript { + repositories { + mavenCentral() + google() // For androidx.navigation + } + + dependencies { + // Exception tracking when using proguard + classpath "io.sentry:sentry-android-gradle-plugin:$rootProject.ext.sentryAndroidGradlePluginVersion" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.5.3" + } +} + +android { + namespace 'de.cyface.app.digural' + compileSdkVersion rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + + applicationId = "de.cyface.app.digural" + versionCode rootProject.ext.versionCode + versionName rootProject.ext.versionName + + testInstrumentationRunner rootProject.ext.testInstrumentationRunner + + vectorDrawables.useSupportLibrary = true + + buildConfigField("String", "localHostIp", '"10.0.2.2"') + // we don't describe this provider build variable in the README so it's probably deprecated + // buildConfigField("String", "provider", '"de.cyface.app.provider"') + + // If our terms change that much that they to be re-accepted, increase this (see Confluence!) + buildConfigField "int", "currentTerms", "5" + + // Ensure that the backend's "CyfaceFull" flavor is always used + missingDimensionStrategy 'project', 'cyface' + missingDimensionStrategy 'mode', 'full' + + // Placeholders for AndroidManifest.xml + manifestPlaceholders = [ + // Load Google Maps API key + googleMapsApiKey:"${project.findProperty('google.maps_api_key')}", + + // Define app link scheme for AppAuth redirect + // Ensure this is consistent with the redirect URI defined below in `oauthRedirect` + // or specify additional redirect URIs in AndroidManifest.xml + 'appAuthRedirectScheme': 'de.cyface.app.digural' + ] + + // oauth redirect uri + buildConfigField "String", "oauthRedirect", "\"${project.findProperty('cyface.oauth_redirect.digural')}\"" + + // Load hCaptcha API key + buildConfigField "String", "hCaptchaKey", "\"${project.findProperty('hCaptcha.key')}\"" + } + + buildTypes { + debug { + // Run code coverage reports by default on debug builds. + testCoverageEnabled = true + + // Select one of the APIs below, depending on your needs + + // Phone - to local collector - ! only if iptables allow connection from outside + //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.local_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.local_oauth_discovery')}\"" + //manifestPlaceholders = [usesCleartextTraffic:"true"] + + // Phone - to local production - ! only if iptables allow connection from outside + // CertPathValidatorException: Trust anchor for certification path not found. + //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.local_production_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.local_oauth_discovery')}\"" + //manifestPlaceholders = [usesCleartextTraffic:"false"] + + // EMULATOR - to local collector + //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.emulator_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.emulator_oauth_discovery')}\"" + //manifestPlaceholders = [usesCleartextTraffic:"true"] // for local collector testing + + // Staging + buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.staging_api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.staging_incentives_api')}\"" + buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.staging_oauth_discovery')}\"" + buildConfigField "String", "testLogin", "\"${project.findProperty('cyface.staging_user')}\"" + buildConfigField "String", "testPassword", "\"${project.findProperty('cyface.staging_password')}\"" + manifestPlaceholders = [usesCleartextTraffic:"false"] + + // MOCK-API - only supports login - used by UI test on CI + //buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.demo_api')}\"" + //buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.demo_incentives_api')}\"" + //buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.demo_oauth_discovery')}\"" + //manifestPlaceholders = [usesCleartextTraffic:"false"] // for local collector testing + } + release { + // mapping.xml file required to decode stack traces, but it's included in the bundle + minifyEnabled true + // https://developer.android.com/studio/build/shrink-code.html + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + + // signingConfig is set by the CI + buildConfigField "String", "cyfaceServer", "\"${project.findProperty('cyface.api')}\"" + buildConfigField "String", "incentivesServer", "\"${project.findProperty('cyface.incentives_api')}\"" + buildConfigField "String", "oauthDiscovery", "\"${project.findProperty('cyface.oauth_discovery')}\"" + manifestPlaceholders = [usesCleartextTraffic:"false"] + } + } + + testOptions { + unitTests { + // Required so that logging methods do not throw not mocked exceptions in junit tests. + returnDefaultValues = true + } + } + + lint { + abortOnError false + } + + packagingOptions { + resources { + // Avoids error when importing jackson + pickFirsts += ['META-INF/LICENSE.txt', 'META-INF/NOTICE.txt', 'META-INF/license.txt', 'META-INF/notice.txt', 'META-INF/ASL2.0', 'META-INF/LICENSE', 'META-INF/NOTICE'] + // To resolve the conflict warning after adding google-api-client dependency + excludes += ['META-INF/DEPENDENCIES'] + excludes += ['META-INF/INDEX.LIST'] + } + } + + // Enabling desugaring to support Java 8 and Java 11 features + compileOptions { + sourceCompatibility rootProject.ext.sourceCompatibility + targetCompatibility rootProject.ext.targetCompatibility + } + + kotlinOptions { + jvmTarget = rootProject.ext.kotlinTargetJavaVersion + } + + buildFeatures { + viewBinding true + } +} + +// Exception tracking +sentry { + // Enables or disables the automatic upload of mapping files + // during a build. If you disable this, you'll need to manually + // upload the mapping files with sentry-cli when you do a release. + autoUpload = true + + // Disables or enables the automatic configuration of Native Symbols + // for Sentry. This executes sentry-cli automatically so + // you don't need to do it manually. + // Default is disabled. + uploadNativeSymbols = false + + // Does or doesn't include the source code of native code for Sentry. + // This executes sentry-cli with the --include-sources param. automatically so + // you don't need to do it manually. + // Default is disabled. + includeNativeSources = false +} + +dependencies { + // Exception tracking + implementation "io.sentry:sentry-android:$rootProject.ext.sentryAndroidVersion" + + // To change the material design + implementation "androidx.appcompat:appcompat:$rootProject.ext.androidxAppCompatVersion" + // To use material elements (tabs, nav bar, slider, etc.) + implementation "com.google.android.material:material:$rootProject.ext.materialVersion" + // To use the nav drawer + implementation "androidx.recyclerview:recyclerview:$rootProject.ext.androidxRecyclerViewVersion" + // Progress bar used to display upload progress + implementation "com.github.lzyzsd:circleprogress:$rootProject.ext.circelProgressVersion" + // To use Google Map + implementation "com.google.android.gms:play-services-maps:$rootProject.ext.mapPlayServicesVersion" + implementation "com.google.android.gms:play-services-location:$rootProject.ext.locationPlayServicesVersion" + // To stream data changes via LiveData and ViewModel to the UI + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.ext.lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.ext.lifecycleVersion" + // To use the navigation graph + implementation "androidx.navigation:navigation-fragment-ktx:$rootProject.ext.navigationVersion" + // For the action bar (at the bottom of the screen) + implementation "androidx.navigation:navigation-ui-ktx:$rootProject.ext.navigationVersion" + implementation "androidx.localbroadcastmanager:localbroadcastmanager:$rootProject.ext.localbroadcastmanagerVersion" + implementation "androidx.preference:preference:$rootProject.ext.androidPreferencesVersion" + + // HCaptcha + implementation "com.github.hcaptcha:hcaptcha-android-sdk:$rootProject.ext.hCaptchaVersion" + + // OAuth 2.0 with OpenID Connect + implementation "net.openid:appauth:$rootProject.ext.appAuthVersion" // Move to uploader [RFR-581] + + // Cyface dependencies + implementation "de.cyface:android-utils:$rootProject.ext.cyfaceUtilsVersion" + implementation project(':datacapturing') + implementation project(':synchronization') + implementation project(':persistence') + implementation project(':bluetooth-le') + implementation project(':energy_settings') + implementation project(':camera_service') + implementation project(':utils') + + // Dependencies for instrumentation tests + // Resolve conflicts between main and test APK (which is used in the integration-test module): + androidTestImplementation "androidx.annotation:annotation:$rootProject.ext.androidxAnnotationVersion" + androidTestImplementation "androidx.test:core:$rootProject.ext.androidxTestCoreVersion" + androidTestImplementation "androidx.test.ext:junit:$rootProject.ext.junitVersion" + androidTestImplementation "androidx.test:runner:$rootProject.ext.runnerVersion" + androidTestImplementation "androidx.test:rules:$rootProject.ext.rulesVersion" + // UiAutomator Testing + androidTestImplementation "androidx.test.uiautomator:uiautomator:$rootProject.ext.uiAutomatorVersion" + androidTestImplementation "org.hamcrest:hamcrest-integration:$rootProject.ext.hamcrestVersion" + + // Dependencies for local unit tests + // - If Junit symbols are not resolvable in IntelliJ, make sure Build Variant is set to debug + // - Loading another dependency (e.g. module) only it's production dependencies (compile) are loaded but not other dependencies (e.g. testCompile) + testImplementation "androidx.test.ext:junit:$rootProject.ext.junitVersion" + testImplementation "org.mockito:mockito-core:$rootProject.ext.mockitoVersion" + // Optional - For better debuggable asserts + testImplementation "org.hamcrest:hamcrest-all:$rootProject.ext.hamcrestVersion" +} diff --git a/ui/digural/proguard-rules.pro b/ui/digural/proguard-rules.pro new file mode 100644 index 00000000..2d8df6b5 --- /dev/null +++ b/ui/digural/proguard-rules.pro @@ -0,0 +1,46 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# These lines prevent Proguard from doing anything but removing unwanted log statements. +-dontwarn ** +-target 1.7 +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-dontpreverify +-verbose + +-optimizations !code/simplification/arithmetic,!code/allocation/variable +-keep class ** +-keepclassmembers class *{*;} +-keepattributes * + +# When using optimized proguard, see https://stackoverflow.com/a/45076261/5815054 +-optimizations !code/simplification/cast,!code/simplification/advanced,!field/*,!class/merging/*,!method/removal/parameter,!method/propagation/parameter + +# In order for this flag to work we need to use proguard-optimize, see e.g. https://goo.gl/1DpWh7 +# This will strip `Log.v`, `Log.d`, and `Log.i` statements and will leave `Log.w` and `Log.e` statements intact. +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static *** v(...); + public static *** d(...); + public static *** i(...); +} \ No newline at end of file diff --git a/ui/digural/src/debug/assets/logback.xml b/ui/digural/src/debug/assets/logback.xml new file mode 100644 index 00000000..92245c95 --- /dev/null +++ b/ui/digural/src/debug/assets/logback.xml @@ -0,0 +1,23 @@ + + + + + + + %msg%n + + + + + ${EXT_FILES_DIR}/cyface.log + true + + %-4relative [%thread] %-5level %logger{35} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/ui/digural/src/main/AndroidManifest.xml b/ui/digural/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3949d917 --- /dev/null +++ b/ui/digural/src/main/AndroidManifest.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/digural/src/main/ic_logo-playstore.png b/ui/digural/src/main/ic_logo-playstore.png new file mode 100644 index 00000000..8f5a1c4d Binary files /dev/null and b/ui/digural/src/main/ic_logo-playstore.png differ diff --git a/ui/digural/src/main/java/de/cyface/app/digural/ui/button/DataCapturingButton.java b/ui/digural/src/main/java/de/cyface/app/digural/ui/button/DataCapturingButton.java new file mode 100644 index 00000000..42229eb5 --- /dev/null +++ b/ui/digural/src/main/java/de/cyface/app/digural/ui/button/DataCapturingButton.java @@ -0,0 +1,992 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.ui.button; + +import static de.cyface.app.digural.utils.Constants.TAG; +import static de.cyface.datacapturing.DataCapturingService.IS_RUNNING_CALLBACK_TIMEOUT; +import static de.cyface.energy_settings.TrackingSettings.isBackgroundProcessingRestricted; +import static de.cyface.energy_settings.TrackingSettings.isEnergySaferActive; +import static de.cyface.energy_settings.TrackingSettings.isGnssEnabled; +import static de.cyface.energy_settings.TrackingSettings.isProblematicManufacturer; +import static de.cyface.energy_settings.TrackingSettings.showEnergySaferWarningDialog; +import static de.cyface.energy_settings.TrackingSettings.showGnssWarningDialog; +import static de.cyface.energy_settings.TrackingSettings.showRestrictedBackgroundProcessingWarningDialog; +import static de.cyface.persistence.model.EventType.MODALITY_TYPE_CHANGE; +import static de.cyface.persistence.model.MeasurementStatus.FINISHED; +import static de.cyface.persistence.model.MeasurementStatus.OPEN; +import static de.cyface.persistence.model.MeasurementStatus.PAUSED; +import static de.cyface.utils.DiskConsumption.spaceAvailable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import com.github.lzyzsd.circleprogress.DonutProgress; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.location.LocationManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import de.cyface.app.digural.CapturingFragment; +import de.cyface.app.digural.R; +import de.cyface.app.digural.button.AbstractButton; +import de.cyface.app.digural.button.ButtonListener; +import de.cyface.app.utils.CalibrationDialogListener; +import de.cyface.app.utils.Map; +import de.cyface.camera_service.CameraListener; +import de.cyface.camera_service.CameraPreferences; +import de.cyface.camera_service.CameraService; +import de.cyface.camera_service.Constants; +import de.cyface.camera_service.UIListener; +import de.cyface.datacapturing.CyfaceDataCapturingService; +import de.cyface.datacapturing.DataCapturingListener; +import de.cyface.datacapturing.DataCapturingService; +import de.cyface.datacapturing.IsRunningCallback; +import de.cyface.datacapturing.ShutDownFinishedHandler; +import de.cyface.datacapturing.StartUpFinishedHandler; +import de.cyface.datacapturing.exception.DataCapturingException; +import de.cyface.datacapturing.exception.MissingPermissionException; +import de.cyface.datacapturing.model.CapturedData; +import de.cyface.datacapturing.ui.Reason; +import de.cyface.persistence.DefaultPersistenceBehaviour; +import de.cyface.persistence.DefaultPersistenceLayer; +import de.cyface.persistence.exception.NoSuchMeasurementException; +import de.cyface.persistence.model.Event; +import de.cyface.persistence.model.Measurement; +import de.cyface.persistence.model.MeasurementStatus; +import de.cyface.persistence.model.Modality; +import de.cyface.persistence.model.ParcelableGeoLocation; +import de.cyface.persistence.model.Track; +import de.cyface.persistence.strategy.DefaultLocationCleaning; +import de.cyface.utils.AppPreferences; +import de.cyface.utils.DiskConsumption; +import de.cyface.utils.Validate; +import io.sentry.Sentry; + +/** + * The button listener for the button to start and stop the data capturing service. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 3.8.3 + * @since 1.0.0 + */ +public class DataCapturingButton + implements AbstractButton, DataCapturingListener, View.OnLongClickListener, CameraListener { + + // TODO [CY-3855]: communication with MainFragment/Activity should use this listener instead of hard-coded + // implementation + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + private final Collection listener; + private MeasurementStatus buttonStatus; + private Map map; + private LocationManager locationManager; + private Context context; + /** + * The {@link CyfaceDataCapturingService} required to control and check the capturing process. + */ + private CyfaceDataCapturingService dataCapturingService = null; + /** + * The {@link CameraService} required to control and check the visual capturing process. + */ + private CameraService cameraService = null; + private AppPreferences preferences; + private CameraPreferences cameraPreferences; + private final static long CALIBRATION_DIALOG_TIMEOUT = 1500L; + private Collection calibrationDialogListener; + /** + * The actual java button object, this class implements behaviour for. + */ + private ImageButton button; + /** + * The {@code TextView} use to show the {@link Measurement#getDistance()} for an ongoing measurement + */ + private TextView distanceTextView; + /** + * The {@code TextView} use to show the info from the {@link CameraListener} for an ongoing measurement + */ + private TextView cameraInfoTextView; + /** + * The {@code TextView} use to show the {@link Measurement#getId()} for an ongoing measurement + */ + private TextView measurementIdTextView; + /** + * {@link DefaultPersistenceLayer} to show the {@link Measurement#getDistance()} of the currently captured + * {@link Measurement} + */ + private DefaultPersistenceLayer persistenceLayer; + private final CapturingFragment capturingFragment; + /** + * Caching the {@link Track}s of the current {@link Measurement}, so we do not need to ask the database each time + * the updated track is requested. This is {@code null} if there is no unfinished measurement. + */ + private List currentMeasurementsTracks; + /** + * Helps to reduce the Sentry quota used. We only want to receive this event once for each DataCapturingButton + * instance not for each location event. + */ + private final boolean[] onNewGeoLocationAcquiredExceptionTriggered = new boolean[] {false, false, false}; + private ProgressDialog calibrationProgressDialog; + + public DataCapturingButton(@NonNull final CapturingFragment capturingFragment) { + this.listener = new HashSet<>(); + this.capturingFragment = capturingFragment; + } + + @Override + public void onCreateView(final ImageButton button, final DonutProgress ISNULL) { + Validate.notNull(button); + + // In order to be able to execute runOnUiThread in onResume + this.context = button.getContext(); + + this.button = button; + this.measurementIdTextView = button.getRootView().findViewById(R.id.data_capturing_measurement_id); + this.distanceTextView = button.getRootView().findViewById(R.id.data_capturing_distance); + this.cameraInfoTextView = button.getRootView().findViewById(R.id.camera_capturing_info); + + // To get the vehicle + preferences = new AppPreferences(context); + cameraPreferences = new CameraPreferences(context); + + // To load the measurement distance + this.persistenceLayer = new DefaultPersistenceLayer<>(context, new DefaultPersistenceBehaviour()); + + button.setOnClickListener(this); + button.setOnLongClickListener(this); + calibrationDialogListener = new HashSet<>(); + locationManager = (LocationManager)button.getContext().getSystemService(Context.LOCATION_SERVICE); + } + + public void bindMap(Map map) { + this.map = map; + } + + /** + * This method helps to access the button via UI thread from a handler thread. + */ + private void runOnUiThread(@NonNull final Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + + /** + * Updates the "is enabled" state of the button on the ui thread. + * + * @param button The {@link Button} to access the {@link Activity} to run code on the UI thread + */ + private void setButtonEnabled(@NonNull final ImageButton button) { + runOnUiThread(() -> button.setEnabled(true)); + } + + /** + * Sets the {@link Button} icon to indicate the next reachable state after a click on the button. + * + * @param button The {@link Button} to access the {@link Activity} to run code on the UI thread + * @param newStatus the new status of the measurement + */ + private void setButtonStatus(@NonNull final ImageButton button, @NonNull final MeasurementStatus newStatus) { + + if (newStatus == buttonStatus) { + Log.d(TAG, "setButtonStatus ignored, button is already in correct state."); + return; + } + + buttonStatus = newStatus; + runOnUiThread(() -> { + // noinspection ConstantConditions - this happened on Emulator N5X on app close + if (button == null) { + Log.w(TAG, "CapturingButton is null, not updating button status"); + return; + } + + updateButtonView(newStatus); + updateOngoingCapturingInfo(newStatus); + + if (map != null) { + if (newStatus == OPEN) { + setLocationListener(); + } else { + unsetLocationListener(); + } + } + }); + } + + /** + * Updates the {@code TextView}s depending on the current {@link MeasurementStatus}. + *

+ * When a new Capturing is started, the {@code TextView} will only show the {@link Measurement#getId()} + * of the open {@link Measurement}. The {@link Measurement#getDistance()} is automatically updated as soon as the + * first {@link ParcelableGeoLocation}s are captured. This way the user can see if the capturing actually works. + * + * @param status the state of the {@code DataCapturingButton} + */ + private void updateOngoingCapturingInfo(@NonNull final MeasurementStatus status) { + if (status == OPEN) { + try { + final String measurementIdText = context.getString(de.cyface.app.utils.R.string.measurement) + " " + + persistenceLayer.loadCurrentlyCapturedMeasurement().getId(); + measurementIdTextView.setText(measurementIdText); + cameraInfoTextView.setVisibility(View.VISIBLE); + } catch (NoSuchMeasurementException e) { + throw new IllegalStateException(e); + } + } else { + // This way you can notice if a GeoLocation/Picture was already captured or not + distanceTextView.setText(""); + // Disabling or else the text is updated when JpegSafer handles image after capturing stopped + cameraInfoTextView.setText(""); + cameraInfoTextView.setVisibility(View.INVISIBLE); + measurementIdTextView.setText(""); + } + } + + /** + * Updates the view of the {@link DataCapturingButton} depending on it's {@param netState}. + *

+ * The button view indicates the next state to be reached after the button is clicked again. + * + * @param status the state of the {@code DataCapturingButton} + */ + private void updateButtonView(@NonNull final MeasurementStatus status) { + + switch (status) { + case OPEN: + button.setImageResource(R.drawable.ic_stop); + break; + case PAUSED: + button.setImageResource(R.drawable.ic_resume); + break; + case FINISHED: + button.setImageResource(R.drawable.ic_play); + break; + default: + throw new IllegalArgumentException("Invalid button state: " + status); + } + } + + private void setLocationListener() throws SecurityException { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 5000L, 0f, map); + } + + private void unsetLocationListener() throws SecurityException { + locationManager.removeUpdates(map); + } + + public void onPause() { + unsetLocationListener(); + dataCapturingService.removeDataCapturingListener(this); + cameraService.removeCameraListener(this); + } + + /** + * We cannot set the {@link DataCapturingService} in the constructor as there is a circular dependency: + * - the {@code DataCapturingService} constructor requires the {@link DataCapturingButton} + * ({@link DataCapturingListener} + * - the {@code DataCapturingButton} requires the {@code DataCapturingService} + *

+ * Thus, we first create the {@code DataCapturingButton}, then reference it when creating the + * {@code DataCapturingService} and then register the {@code DataCapturingService} in here to the button. + *

+ * This also sets the {@link DataCapturingButton} status to the correct state. + * + * @param dataCapturingService The {@code DataCapturingService} required to control and check the capturing. + * @param cameraService The {@code CameraService} required to control and check the visual capturing. + */ + public void onResume(@NonNull final CyfaceDataCapturingService dataCapturingService, + @NonNull final CameraService cameraService) { + Log.d(TAG, "onResume: reconnecting ..."); + this.dataCapturingService = dataCapturingService; + this.cameraService = cameraService; + updateCachedTrack(); + dataCapturingService.addDataCapturingListener(this); + cameraService.addCameraListener(this); + + // OPEN: running capturing + if (dataCapturingService.reconnect(IS_RUNNING_CALLBACK_TIMEOUT)) { + Log.d(TAG, "onResume: reconnecting DCS succeeded"); + setLocationListener(); + // We re-sync the button here as the data capturing can be canceled while the app is closed + setButtonStatus(button, OPEN); + setButtonEnabled(button); + // mainFragment.showUnfinishedTracksOnMap(persistenceLayer.loadCurrentlyCapturedMeasurement().getIdentifier()); + + // Also try to reconnect to CameraService if it's alive + if (cameraService.reconnect(IS_RUNNING_CALLBACK_TIMEOUT)) { + // It does not matter whether isCameraServiceRequested() as this can change all the time + Log.d(TAG, "onResume: reconnecting CameraService succeeded"); + } + return; + } + + // PAUSED or FINISHED capturing + Log.d(TAG, "onResume: reconnecting timed out"); + if (persistenceLayer.hasMeasurement(PAUSED)) { + setButtonStatus(button, PAUSED); + // mainFragment.showUnfinishedTracksOnMap(persistenceLayer.loadCurrentlyCapturedMeasurement().getIdentifier()); + } else { + setButtonStatus(button, FINISHED); + } + setButtonEnabled(button); + + // Check if there is a zombie CameraService running + // In case anything went wrong and the camera is still bound by this app we're releasing it so that it + // can be used by other apps again + if (cameraService.reconnect(IS_RUNNING_CALLBACK_TIMEOUT)) { + Log.w(Constants.TAG, "Zombie CameraService is running and it's " + + (cameraPreferences.getCameraEnabled() ? "" : "*not*") + " requested"); + cameraService.stop( + new ShutDownFinishedHandler(de.cyface.camera_service.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(long measurementIdentifier) { + Log.d(TAG, "onResume: zombie CameraService stopped"); + } + }); + throw new IllegalStateException( + "Camera stopped manually as the camera was not released. This should not happen!"); + } + } + + @Override + public void onClick(View view) { + // Disables the button. This is used to avoid a duplicate start command which crashes the SDK + // until CY-4098 is implemented. It's automatically re-enabled as soon as the callback arrives. + button.setEnabled(false); + + // The MaterialDialog implementation of the EnergySettings dialogs are not shown when called + // from inside the IsRunningCallback. Thus, we call it here for now instead of in startCapturing() + if ((buttonStatus.equals(FINISHED) || buttonStatus.equals(PAUSED)) && isRestrictionActive()) { + return; + } + + dataCapturingService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, new IsRunningCallback() { + @Override + public void isRunning() { + Validate.isTrue(buttonStatus == OPEN, "DataCapturingButton is out of sync."); + stopCapturing(); + } + + @Override + public void timedOut() { + Validate.isTrue(buttonStatus != OPEN, "DataCapturingButton is out of sync."); + + // If Measurement is paused, resume the measurement on a normal click + if (persistenceLayer.hasMeasurement(PAUSED)) { + resumeCapturing(); + return; + } + startCapturing(); + } + }); + } + + @Override + public boolean onLongClick(View v) { + // Disables the button. This is used to avoid a duplicate start command which crashes the SDK + // until CY-4098 is implemented. It's automatically re-enabled as soon as the callback arrives. + button.setEnabled(false); + + // The MaterialDialog implementation of the EnergySettings dialogs are not shown when called + // from inside the IsRunningCallback. Thus, we call it here for now instead of in startCapturing() + if ((buttonStatus.equals(FINISHED) || buttonStatus.equals(PAUSED)) && isRestrictionActive()) { + return true; + } + + dataCapturingService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, new IsRunningCallback() { + @Override + public void isRunning() { + Validate.isTrue(buttonStatus == OPEN, "DataCapturingButton is out of sync."); + pauseCapturing(); + } + + @Override + public void timedOut() { + Validate.isTrue(buttonStatus != OPEN, "DataCapturingButton is out of sync."); + + // If Measurement is paused, stop the measurement on long press + if (persistenceLayer.hasMeasurement(PAUSED)) { + stopCapturing(); + return; + } + startCapturing(); + } + }); + + return true; + } + + /** + * Pause capturing + */ + private void pauseCapturing() { + try { + dataCapturingService.pause( + new ShutDownFinishedHandler(de.cyface.datacapturing.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(final long measurementIdentifier) { + // The measurement id should always be set [STAD-333] + Validate.isTrue(measurementIdentifier != -1, "Missing measurement id"); + setButtonStatus(button, PAUSED); + setButtonEnabled(button); + Toast.makeText(context, R.string.toast_measurement_paused, Toast.LENGTH_SHORT).show(); + } + }); + } catch (final NoSuchMeasurementException e) { + throw new IllegalStateException(e); + } + + // Pause camera capturing if it is running (even is DCS is not running) + // TODO: this way we don't notice when the camera was stopped unexpectedly + cameraService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, + new IsRunningCallback() { + @Override + public void isRunning() { + cameraService.pause(new ShutDownFinishedHandler( + de.cyface.camera_service.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(long measurementIdentifier) { + Log.d(TAG, "pauseCapturing: CameraService stopped"); + } + }); + } + + @Override + public void timedOut() { + Log.d(Constants.TAG, "pauseCapturing: no CameraService running, nothing to do"); + } + }); + } + + /** + * Stop capturing + */ + private void stopCapturing() { + try { + dataCapturingService.stop( + new ShutDownFinishedHandler(de.cyface.datacapturing.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(final long measurementIdentifier) { + // The measurement id should always be set [STAD-333] + Validate.isTrue(measurementIdentifier != -1, "Missing measurement id"); + currentMeasurementsTracks = null; + setButtonStatus(button, FINISHED); + setButtonEnabled(button); + } + }); + runOnUiThread(() -> map.clearMap()); + } catch (final NoSuchMeasurementException e) { + throw new IllegalStateException(e); + } + + // Stop camera capturing if it is running (even is DCS is not running) + // TODO: this way we don't notice when the camera was stopped unexpectedly + cameraService.isRunning(IS_RUNNING_CALLBACK_TIMEOUT, TimeUnit.MILLISECONDS, new IsRunningCallback() { + @Override + public void isRunning() { + cameraService.stop(new ShutDownFinishedHandler( + de.cyface.camera_service.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(long measurementIdentifier) { + Log.d(TAG, "stopCapturing: CameraService stopped"); + } + }); + } + + @Override + public void timedOut() { + Log.d(Constants.TAG, "stopCapturing: no CameraService running, nothing to do"); + } + }); + } + + /** + * Starts capturing + */ + private void startCapturing() { + + // Measurement is stopped, so we start a new measurement + if (persistenceLayer.hasMeasurement(OPEN) && isProblematicManufacturer()) { + showToastOnMainThread( + context.getString(de.cyface.app.utils.R.string.toast_last_tracking_crashed), + true); + } + + // We use a handler to run the UI Code on the main thread as it is supposed to be + runOnUiThread(() -> { + calibrationProgressDialog = createAndShowCalibrationDialog(); + scheduleProgressDialogDismissal(calibrationProgressDialog, calibrationDialogListener); + }); + + // TODO [CY-3855]: we have to provide a listener for the button (<- ???) + try { + final var modality = Modality.valueOf(preferences.getModality()); + Validate.notNull(modality); + + currentMeasurementsTracks = new ArrayList<>(); + currentMeasurementsTracks.add(new Track()); + + dataCapturingService.start(modality, + new StartUpFinishedHandler(de.cyface.datacapturing.MessageCodes.GLOBAL_BROADCAST_SERVICE_STARTED) { + @Override + public void startUpFinished(final long measurementIdentifier) { + // The measurement id should always be set [STAD-333] + Validate.isTrue(measurementIdentifier != -1, "Missing measurement id"); + Log.v(TAG, "startUpFinished"); + setButtonStatus(button, OPEN); + setButtonEnabled(button); + + // Start CameraService + if (cameraPreferences.getCameraEnabled()) { + Log.d(Constants.TAG, "CameraServiceRequested"); + try { + startCameraService(measurementIdentifier); + } catch (DataCapturingException | MissingPermissionException e) { + throw new IllegalStateException(e); + } + } + } + }); + } catch (final DataCapturingException | MissingPermissionException e) { + throw new IllegalStateException(e); + } + } + + private boolean isRestrictionActive() { + + if (context == null) { + Log.w(TAG, "Context is null, restrictions cannot be checked"); + return false; + } + final Activity activity = capturingFragment.getActivity(); + if (activity == null) { + Log.w(TAG, "Activity is null. If needed, dialogs wont appear."); + } + + if (!spaceAvailable()) { + showToastOnMainThread( + context.getString(de.cyface.app.utils.R.string.error_message_capturing_canceled_no_space), false); + setButtonEnabled(button); + return true; + } + if (!isGnssEnabled(context)) { + showGnssWarningDialog(activity); + setButtonEnabled(button); + return true; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isEnergySaferActive(context)) { + showEnergySaferWarningDialog(activity); + setButtonEnabled(button); + return true; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && isBackgroundProcessingRestricted(context)) { + showRestrictedBackgroundProcessingWarningDialog(activity); + setButtonEnabled(button); + return true; + } + return false; + } + + /** + * Resumes capturing + */ + private void resumeCapturing() { + + Log.d(TAG, "resumeCachedTrack: Adding new sub track to existing cached track"); + currentMeasurementsTracks.add(new Track()); + + try { + dataCapturingService.resume( + new StartUpFinishedHandler(de.cyface.datacapturing.MessageCodes.GLOBAL_BROADCAST_SERVICE_STARTED) { + @Override + public void startUpFinished(final long measurementIdentifier) { + // The measurement id should always be set [STAD-333] + Validate.isTrue(measurementIdentifier != -1, "Missing measurement id"); + Log.v(TAG, "resumeCapturing: startUpFinished"); + setButtonStatus(button, OPEN); + setButtonEnabled(button); + Toast.makeText(context, R.string.toast_measurement_resumed, Toast.LENGTH_SHORT).show(); + + // Start CameraService + if (cameraPreferences.getCameraEnabled()) { + Log.d(Constants.TAG, "CameraServiceRequested"); + try { + startCameraService(measurementIdentifier); + } catch (DataCapturingException | MissingPermissionException e) { + throw new IllegalStateException(e); + } + } + } + }); + } catch (final NoSuchMeasurementException | DataCapturingException | MissingPermissionException e) { + throw new IllegalStateException(e); + } + } + + /** + * Shows a toast message explicitly on the main thread. + * + * @param toastMessage The message to show + * @param longDuration {@code True} if the toast should be shown for a longer time + */ + private void showToastOnMainThread(final String toastMessage, final boolean longDuration) { + new Handler(Looper.getMainLooper()).post(() -> Toast + .makeText(context, toastMessage, longDuration ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show()); + } + + /** + * Starts the camera service and, thus, the camera capturing. + * + * @param measurementId the id of the measurement for which camera data is to be captured + * @throws DataCapturingException If the asynchronous background service did not start successfully or no valid + * Android context was available. + * @throws MissingPermissionException If no Android ACCESS_FINE_LOCATION has been granted. You may + * register a {@link UIListener} to ask the user for this permission and prevent the + * Exception. If the Exception was thrown the service does not start. + */ + private void startCameraService(final long measurementId) + throws DataCapturingException, MissingPermissionException { + + final var rawModeSelected = cameraPreferences.getRawMode(); + final var videoModeSelected = cameraPreferences.getVideoMode(); + // We need to load and pass the preferences for the camera focus here as the preferences + // do not work reliably on multi-process access. https://stackoverflow.com/a/27987956/5815054 + final var staticFocusSelected = cameraPreferences.getStaticFocus(); + final var staticFocusDistance = cameraPreferences.getStaticFocusDistance(); + final var distanceBasedTriggeringSelected = cameraPreferences.getDistanceBasedTriggering(); + final var triggeringDistance = cameraPreferences.getTriggeringDistance(); + final var staticExposureTimeSelected = cameraPreferences.getStaticExposure(); + final var staticExposureTime = cameraPreferences.getStaticExposureTime(); + final var exposureValueIso100 = cameraPreferences.getStaticExposureValue(); + + cameraService.start(measurementId, videoModeSelected, rawModeSelected, staticFocusSelected, + staticFocusDistance, staticExposureTimeSelected, staticExposureTime, exposureValueIso100, + distanceBasedTriggeringSelected, triggeringDistance, + new StartUpFinishedHandler(de.cyface.camera_service.MessageCodes.GLOBAL_BROADCAST_SERVICE_STARTED) { + @Override + public void startUpFinished(final long measurementIdentifier) { + Log.v(Constants.TAG, "startCameraService: CameraService startUpFinished"); + } + }); + } + + /** + * Creates and shows {@link ProgressDialog} to inform the user about calibration at the beginning of a + * measurement. + * + * @return A reference to the ProgressDialog which can be used to dismiss it. + */ + private ProgressDialog createAndShowCalibrationDialog() { + return ProgressDialog.show(context, + context.getString(de.cyface.app.utils.R.string.title_dialog_starting_data_capture), + context.getString(de.cyface.app.utils.R.string.msg_calibrating), true, false, dialog -> { + try { + dataCapturingService + .stop(new ShutDownFinishedHandler( + de.cyface.datacapturing.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(final long l) { + // nothing to do + } + }); + if (cameraPreferences.getCameraEnabled()) { + cameraService.stop(new ShutDownFinishedHandler( + de.cyface.camera_service.MessageCodes.LOCAL_BROADCAST_SERVICE_STOPPED) { + @Override + public void shutDownFinished(final long l) { + // nothing to do + } + }); + } + } catch (final NoSuchMeasurementException e) { + throw new IllegalStateException(e); + } + }); + } + + /** + * Dismisses a (calibration) ProgressDialog and informs the {@link CalibrationDialogListener}s + * about the dismissal. + * + * @param progressDialog The {@link ProgressDialog} to dismiss + * @param calibrationDialogListener The {@link Collection} to inform + */ + private void scheduleProgressDialogDismissal(final ProgressDialog progressDialog, + final Collection calibrationDialogListener) { + new Handler().postDelayed(() -> dismissCalibrationDialog(progressDialog, calibrationDialogListener), + CALIBRATION_DIALOG_TIMEOUT); + } + + private void dismissCalibrationDialog(final ProgressDialog progressDialog, + final Collection calibrationDialogListener) { + if (progressDialog != null) { + progressDialog.dismiss(); + for (CalibrationDialogListener calibrationDialogListener1 : calibrationDialogListener) { + calibrationDialogListener1.onCalibrationDialogFinished(); + } + } + } + + /** + * Loads the track of the currently captured measurement from the database. + *

+ * This is required when the app is resumed after being in background and probably missing locations + * and when the app is restarted. + */ + private void updateCachedTrack() { + try { + if (!persistenceLayer.hasMeasurement(MeasurementStatus.OPEN) + && !persistenceLayer.hasMeasurement(MeasurementStatus.PAUSED)) { + Log.d(TAG, "updateCachedTrack: No unfinished measurement found, un-setting cache."); + currentMeasurementsTracks = null; + return; + } + + Log.d(TAG, "updateCachedTrack: Unfinished measurement found, loading track from database."); + final Measurement measurement = dataCapturingService.loadCurrentlyCapturedMeasurement(); + final List loadedList = persistenceLayer.loadTracks(measurement.getId(), + new DefaultLocationCleaning()); + // We need to make sure we return a list which supports "add" even when an empty list is returned + // or else the onHostResume method cannot add a new sub track to a loaded empty list + currentMeasurementsTracks = new ArrayList<>(loadedList); + } catch (NoSuchMeasurementException e) { + throw new RuntimeException(e); + } + } + + public List getCurrentMeasurementsTracks() { + return currentMeasurementsTracks; + } + + public List loadCurrentMeasurementsEvents() throws NoSuchMeasurementException { + final Measurement measurement = dataCapturingService.loadCurrentlyCapturedMeasurement(); + return persistenceLayer.loadEvents(measurement.getId(), MODALITY_TYPE_CHANGE); + } + + @Override + public void onDestroyView() { + button.setOnClickListener(null); + disconnect(dataCapturingService, cameraService); + dismissCalibrationDialog(calibrationProgressDialog, calibrationDialogListener); + } + + /** + * Unbinds the services. They continue to run in the background but won't send any updates to this button. + *

+ * Instead of only disconnecting when `isRunning()` returns a timeout we always disconnect. + * This way the race condition between view destruction and timeout is prevented [MOV-588]. + * + * @param dataCapturingService the capturing service to unregister from + * @param cameraService the camera service to unregister from + */ + private void disconnect(final DataCapturingService dataCapturingService, final CameraService cameraService) { + if (dataCapturingService == null) { + Log.w(TAG, "Skipping DCS.disconnect() as DCS is null"); + // This should not happen, thus, reporting to Sentry + + if (preferences.getReportingAccepted()) { + Sentry.captureMessage("DCButton.onDestroyView: dataCapturingService is null"); + } + } else { + try { + dataCapturingService.disconnect(); + } catch (DataCapturingException e) { + // This just tells us there is no running capturing in the background, see [MOV-588] + Log.d(TAG, "No need to unbind as the background service was not running."); + } + } + + if (cameraService == null) { + Log.d(TAG, "Skipping CameraService.disconnect() as CameraService is null"); + // No need to capture this as this is always null when camera is disabled + } else { + try { + cameraService.disconnect(); + } catch (DataCapturingException e) { + // This just tells us there is no running capturing in the background, see [MOV-588] + Log.d(TAG, "No need to unbind as the camera background service was not running."); + } + } + } + + @Override + public void addButtonListener(final ButtonListener buttonListener) { + Validate.notNull(buttonListener); + this.listener.add(buttonListener); + } + + @Override + public void onFixAcquired() { + // Nothing to do + } + + @Override + public void onFixLost() { + // Nothing to do + } + + @Override + public void onNewGeoLocationAcquired(ParcelableGeoLocation geoLocation) { + Log.d(TAG, "onNewGeoLocationAcquired"); + final Measurement measurement; + try { + measurement = persistenceLayer.loadCurrentlyCapturedMeasurement(); + } catch (final NoSuchMeasurementException e) { + // GeoLocations may also arrive shortly after a measurement was stopped. Thus, this may not crash. + // This happened on the Emulator with emulated live locations. + Log.w(TAG, "onNewGeoLocationAcquired: No currently captured measurement found, doing nothing."); + if (!onNewGeoLocationAcquiredExceptionTriggered[0] && preferences.getReportingAccepted()) { + onNewGeoLocationAcquiredExceptionTriggered[0] = true; + Sentry.captureException(e); + } + return; + } + final int distanceMeter = (int)Math.round(measurement.getDistance()); + final double distanceKm = distanceMeter == 0 ? 0.0 : distanceMeter / 1000.0; + final String distanceText = distanceKm + " km"; + Log.d(TAG, "Distance update: " + distanceText); + distanceTextView.setText(distanceText); + Log.d(TAG, "distanceTextView: " + distanceTextView.getText()); + Log.d(TAG, "distanceTextView: " + distanceTextView.isEnabled()); + + addLocationToCachedTrack(geoLocation); + final List currentMeasurementsEvents; + try { + currentMeasurementsEvents = loadCurrentMeasurementsEvents(); + var map = capturingFragment.getMap(); + Validate.notNull(map); + map.render(currentMeasurementsTracks, currentMeasurementsEvents, false, + new ArrayList<>()); + } catch (NoSuchMeasurementException e) { + Log.w(TAG, "onNewGeoLocationAcquired() failed to loadCurrentMeasurementsEvents(). " + + "Thus, map.renderMeasurement() is ignored. This should only happen id " + + "the capturing already stopped."); + if (!onNewGeoLocationAcquiredExceptionTriggered[2] && preferences.getReportingAccepted()) { + onNewGeoLocationAcquiredExceptionTriggered[2] = true; + Sentry.captureException(e); + } + } + } + + private void addLocationToCachedTrack(@NonNull final ParcelableGeoLocation location) { + Validate.notNull(currentMeasurementsTracks, "onNewGeoLocation - cached track is null"); + + if (!location.isValid()) { + Log.d(TAG, "updateCachedTrack: ignoring invalid point"); + return; + } + + if (currentMeasurementsTracks.size() == 0) { + Log.d(TAG, "updateCachedTrack: Loaded track is empty, creating new list with empty sub track"); + currentMeasurementsTracks = new ArrayList<>(); + currentMeasurementsTracks.add(new Track()); + } + + currentMeasurementsTracks.get(currentMeasurementsTracks.size() - 1).addLocation(location); + } + + @Override + public void onNewSensorDataAcquired(CapturedData capturedData) { + // Nothing to do here + } + + @Override + public void onNewPictureAcquired(final int picturesCaptured) { + Log.d(Constants.TAG, "onNewPictureAcquired"); + final String text = context.getString(R.string.camera_images) + " " + picturesCaptured; + cameraInfoTextView.setText(text); + Log.d(TAG, "cameraInfoTextView: " + cameraInfoTextView.getText()); + } + + @Override + public void onNewVideoStarted() { + Log.d(Constants.TAG, "onNewVideoStarted"); + } + + @Override + public void onVideoStopped() { + Log.d(Constants.TAG, "onVideoStopped"); + } + + @Override + public void onLowDiskSpace(DiskConsumption diskConsumption) { + // Nothing to do here - handled by DataCapturingEventHandler + } + + @Override + public void onSynchronizationSuccessful() { + // Nothing to do here + } + + @Override + public void onErrorState(final Exception e) { + throw new IllegalStateException(e); + } + + @Override + public boolean onRequiresPermission(final String permission, final Reason reason) { + return false; + } + + @Override + public void onCapturingStopped() { + setButtonStatus(button, FINISHED); + } + + /* + * TODO [CY-3577] re-enable + * Adds an already paired Bluetooth CSC sensor device information to the provided Intent. If no such device is + * found, a warning is issued and no information is added. + *

+ * The information is added as two extras with the keys {@link BluetoothLeSetup#BLUETOOTH_LE_DEVICE} and + * {@link BluetoothLeSetup#WHEEL_CIRCUMFERENCE}. The first is the Android {@link BluetoothDevice} object while + * the second is a double parameter specifying the used vehicles wheel circumference in centimeters. + * + * @ param intent The {@link Intent} to add the parameters to. + * + * private void addBluetoothDeviceToIntent(final Intent intent) { + * final String bluetoothDeviceMac = preferences.getString(BluetoothLeSetup.BLUETOOTHLE_DEVICE_MAC_KEY, ""); + * double wheelCircumference = preferences.getFloat(BluetoothLeSetup.BLUETOOTHLE_WHEEL_CIRCUMFERENCE, 0); + * Validate.notEmpty(bluetoothDeviceMac); + * Validate.isTrue(wheelCircumference > 0); + * + * Log.d(TAG, "Using registered Bluetooth CSC sensor MAC: " + bluetoothDeviceMac + ", Wheel Circumference: " + * + wheelCircumference); + * BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(bluetoothDeviceMac); + * if (device == null) { + * return; + * } + * intent.putExtra(BluetoothLeSetup.BLUETOOTH_LE_DEVICE, device); + * intent.putExtra(BluetoothLeSetup.WHEEL_CIRCUMFERENCE, Double.valueOf(wheelCircumference).floatValue()); + * } + */ +} diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/CameraServiceProvider.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/CameraServiceProvider.kt new file mode 100644 index 00000000..b64d0a7f --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/CameraServiceProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural + +import de.cyface.camera_service.CameraService + +/** + * Interface which defines the dependencies implemented by the `MainActivity` to be accessible from + * the `Fragments`. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 7.5.0 + */ +interface CameraServiceProvider { + val cameraService: CameraService +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/CapturingFragment.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/CapturingFragment.kt new file mode 100644 index 00000000..d6c9072d --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/CapturingFragment.kt @@ -0,0 +1,411 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.navigation.fragment.NavHostFragment +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import de.cyface.app.digural.button.SynchronizationButton +import de.cyface.app.digural.capturing.MenuProvider +import de.cyface.app.digural.databinding.FragmentCapturingBinding +import de.cyface.app.digural.dialog.ModalityDialog +import de.cyface.app.digural.ui.button.DataCapturingButton +import de.cyface.app.utils.Map +import de.cyface.app.utils.ServiceProvider +import de.cyface.camera_service.CameraService +import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.datacapturing.persistence.CapturingPersistenceBehaviour +import de.cyface.persistence.DefaultPersistenceLayer +import de.cyface.persistence.exception.NoSuchMeasurementException +import de.cyface.persistence.model.Event +import de.cyface.persistence.model.Modality +import de.cyface.synchronization.ConnectionStatusListener +import de.cyface.utils.AppPreferences +import de.cyface.utils.Validate +import io.sentry.Sentry + +/** + * A `Fragment` for the main UI used for data capturing and supervision of the capturing process. + * + * @author Armin Schnabel + * @version 1.4.3 + * @since 1.0.0 + */ +class CapturingFragment : Fragment(), ConnectionStatusListener { + + /** + * This property is only valid between onCreateView and onDestroyView. + */ + private var _binding: FragmentCapturingBinding? = null + + /** + * The generated class which holds all bindings from the layout file. + */ + private val binding get() = _binding!! + + /** + * The `DataCapturingButton` which allows the user to control the capturing lifecycle. + */ + private var dataCapturingButton: DataCapturingButton? = null + + /** + * The `SynchronizationButton` which allows the user to manually trigger the synchronization + * and to see the synchronization progress. + */ + private var syncButton: SynchronizationButton? = null + + /** + * The `Map` used to visualize the ongoing capturing. + */ + var map: Map? = null + private set + + /** + * The `SharedPreferences` used to store the user's preferences. + */ + private lateinit var preferences: AppPreferences + + /** + * The `DataCapturingService` which represents the API of the Cyface Android SDK. + */ + private lateinit var capturing: CyfaceDataCapturingService + + /** + * An implementation of the persistence layer which caches some data during capturing. + */ + private lateinit var persistence: DefaultPersistenceLayer + + /** + * The `CameraService` which collects camera data if the user did activate this feature. + */ + private lateinit var cameraService: CameraService + + // Ensure onMapReadyRunnable is called after permissions are newly granted + // This launcher must be launched to request permissions + private var permissionLauncher: ActivityResultLauncher> = + registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + if (result.isNotEmpty()) { + val allGranted = result.values.none { !it } + if (allGranted) { + // Only if the map already called it's onMapReady + if (map!!.googleMap != null) { + map!!.onMapReady() + } + } else { + Toast.makeText( + context, + "Location permission repeatedly denies", + Toast.LENGTH_LONG + ).show() + // Close Cyface if permission has not been granted. + // When the user repeatedly denies the location permission, the app won't start + // and only starts again if the permissions are granted manually. + // It was always like this, but if this is a problem we need to add a screen + // which explains the user that this can happen. + requireActivity().finish() + } + } + } + + /** + * The `Runnable` triggered when the `Map` is loaded and ready. + */ + private val onMapReadyRunnable = Runnable { + val currentMeasurementsTracks = + dataCapturingButton!!.currentMeasurementsTracks ?: return@Runnable + val currentMeasurementsEvents: List + try { + currentMeasurementsEvents = dataCapturingButton!!.loadCurrentMeasurementsEvents() + map!!.render(currentMeasurementsTracks, currentMeasurementsEvents, false, ArrayList()) + } catch (e: NoSuchMeasurementException) { + if (preferences.getReportingAccepted()) { + Sentry.captureException(e) + } + Log.w( + TAG, + "onMapReadyRunnable failed to loadCurrentMeasurementsEvents. Thus, map.renderMeasurement() is not executed. This should only happen when the capturing already stopped." + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (activity is ServiceProvider) { + capturing = (activity as ServiceProvider).capturing + persistence = capturing.persistenceLayer + cameraService = (activity as CameraServiceProvider).cameraService + } else { + throw RuntimeException("Context doesn't support the Fragment, implement `ServiceProvider`") + } + } + + /** + * All non-graphical initializations should go into onCreate (which might be called before Activity's onCreate + * finishes). All view-related initializations go into onCreateView and final initializations which depend on the + * Activity's onCreate and the fragment's onCreateView to be finished belong into the onActivityCreated method + */ + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCapturingBinding.inflate(inflater, container, false) + dataCapturingButton = + DataCapturingButton(this) + preferences = AppPreferences(requireContext()) + // Register synchronization listener + capturing.addConnectionStatusListener(this) + syncButton = SynchronizationButton(capturing) + showModalitySelectionDialogIfNeeded() + dataCapturingButton!!.onCreateView( + binding.captureDataMainButton, + null + ) + map = Map( + binding.mapView, + savedInstanceState, + onMapReadyRunnable, + permissionLauncher + ) + syncButton!!.onCreateView( + binding.dataSyncButton, + binding.connectionStatusProgress + ) + dataCapturingButton!!.bindMap(map) + + // Add items to menu (top right) + // Not using `findNavController()` as `FragmentContainerView` in `activity_main.xml` does not + // work with with `findNavController()` (https://stackoverflow.com/a/60434988/5815054). + val navHostFragment = + requireActivity().supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + requireActivity().addMenuProvider( + MenuProvider( + requireActivity() as MainActivity, + navHostFragment.navController + ), + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + + return binding.root + } + + private fun showModalitySelectionDialogIfNeeded() { + registerModalityTabSelectionListener() + if (preferences.getModality() != null) { + selectModalityTab() + return + } + val fragmentManager = fragmentManager + Validate.notNull(fragmentManager) + val dialog = ModalityDialog() + dialog.setTargetFragment(this, DIALOG_INITIAL_MODALITY_SELECTION_REQUEST_CODE) + dialog.isCancelable = false + dialog.show(fragmentManager!!, "MODALITY_DIALOG") + } + + private fun registerModalityTabSelectionListener() { + val tabLayout = binding.modalityTabs + val newModality = arrayOfNulls(1) + tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val oldModalityId = preferences.getModality() + val oldModality = + if (oldModalityId == null) null else Modality.valueOf(oldModalityId) + when (tab.position) { + 0 -> newModality[0] = Modality.CAR + 1 -> newModality[0] = Modality.BICYCLE + 2 -> newModality[0] = Modality.WALKING + 3 -> newModality[0] = Modality.BUS + 4 -> newModality[0] = Modality.TRAIN + else -> throw IllegalArgumentException("Unknown tab selected: " + tab.position) + } + preferences.saveModality(newModality[0]!!.databaseIdentifier) + if (oldModality != null && oldModality == newModality[0]) { + Log.d( + TAG, + "changeModalityType(): old (" + oldModality + " and new Modality (" + newModality[0] + + ") types are equal not recording event." + ) + return + } + capturing.changeModalityType(newModality[0]!!) + + // Deactivated for pro app until we show them their own tiles: + // if (map != null) { map.loadCyfaceTiles(newModality[0].getDatabaseIdentifier()); } + } + + override fun onTabUnselected(tab: TabLayout.Tab) { + // Nothing to do here + } + + override fun onTabReselected(tab: TabLayout.Tab) { + // Nothing to do here + } + }) + } + + /** + * We use the activity result method as a callback from the `Modality` dialog to the main fragment + * to setup the tabs as soon as a [Modality] type is selected the first time + * + * @param requestCode is used to differentiate and identify requests + * @param resultCode is used to describe the request's result + * @param data an intent which may contain result data + */ + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == DIALOG_INITIAL_MODALITY_SELECTION_REQUEST_CODE) { + selectModalityTab() + } + } + + /** + * Depending on which [Modality] is selected in the preferences the respective tab is selected. + * Also, the tiles relative to the selected `Modality` are loaded onto the map, if enabled. + * + * Make sure the order (0, 1, 2 from left(start) to right(end)) in the TabLayout xml file is consistent + * with the order here to map the correct enum to each tab. + */ + private fun selectModalityTab() { + val tabLayout = binding.modalityTabs + val modality = preferences.getModality() + Validate.notNull(modality, "Modality should already be set but isn't.") + + // Select the Modality tab + val tab: TabLayout.Tab? = when (modality) { + Modality.CAR.name -> { + tabLayout.getTabAt(0) + } + + Modality.BICYCLE.name -> { + tabLayout.getTabAt(1) + } + + Modality.WALKING.name -> { + tabLayout.getTabAt(2) + } + + Modality.BUS.name -> { + tabLayout.getTabAt(3) + } + + Modality.TRAIN.name -> { + tabLayout.getTabAt(4) + } + + else -> { + throw IllegalArgumentException("Unknown Modality id: $modality") + } + } + Validate.notNull(tab) + tab!!.select() + } + + override fun onResume() { + super.onResume() + syncButton!!.onResume() + capturing.addConnectionStatusListener(this) + map!!.onResume() + dataCapturingButton!!.onResume(capturing, cameraService) + } + + override fun onPause() { + map!!.onPause() + dataCapturingButton!!.onPause() + capturing.removeConnectionStatusListener(this) + super.onPause() + } + + override fun onSyncStarted() { + if (isAdded) { + syncButton!!.isSynchronizingChanged(true) + } else { + Log.w(TAG, "onSyncStarted called but fragment is not attached") + } + } + + override fun onSyncFinished() { + if (isAdded) { + syncButton!!.isSynchronizingChanged(false) + } else { + Log.w(TAG, "onSyncFinished called but fragment is not attached") + } + } + + override fun onProgress(percent: Float, measurementId: Long) { + if (isAdded) { + Log.v(TAG, "Sync progress received: $percent %, mid: $measurementId") + syncButton!!.updateProgress(percent) + } + } + + override fun onDestroyView() { + syncButton!!.onDestroyView() + dataCapturingButton!!.onDestroyView() + + capturing.removeConnectionStatusListener(this) + Log.d(TAG, "onDestroyView: stopped CyfaceDataCapturingService") + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + var imageView = binding.captureDataMainButton + outState.putInt("capturing_button_resource_id", imageView.id) + imageView = binding.dataSyncButton + outState.putInt("data_sync_button_id", imageView.id) + try { + val donutProgress = binding.connectionStatusProgress + outState.putInt( + "connection_status_progress_id", + donutProgress.id + ) + } catch (e: NullPointerException) { + Log.w(TAG, "Failed to save donutProgress view state") + } + super.onSaveInstanceState(outState) + } + + companion object { + /** + * The identifier for the [ModalityDialog] request which asks the user (initially) to select a + * [Modality] preference. + */ + const val DIALOG_INITIAL_MODALITY_SELECTION_REQUEST_CODE = 201909191 + + /** + * The tag used to identify logging from this class. + */ + private const val TAG = "de.cyface.app.frag.main" + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/MainActivity.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/MainActivity.kt new file mode 100644 index 00000000..0e40cea1 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/MainActivity.kt @@ -0,0 +1,541 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.AccountManagerFuture +import android.accounts.AuthenticatorException +import android.accounts.OperationCanceledException +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Message +import android.util.Log +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import de.cyface.app.digural.auth.LoginActivity +import de.cyface.app.digural.databinding.ActivityMainBinding +import de.cyface.app.digural.notification.CameraEventHandler +import de.cyface.app.digural.notification.DataCapturingEventHandler +import de.cyface.app.digural.utils.Constants +import de.cyface.app.digural.utils.Constants.ACCOUNT_TYPE +import de.cyface.app.digural.utils.Constants.AUTHORITY +import de.cyface.app.utils.ServiceProvider +import de.cyface.camera_service.CameraListener +import de.cyface.camera_service.CameraPreferences +import de.cyface.camera_service.CameraService +import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.datacapturing.DataCapturingListener +import de.cyface.datacapturing.exception.SetupException +import de.cyface.datacapturing.model.CapturedData +import de.cyface.datacapturing.ui.Reason +import de.cyface.energy_settings.TrackingSettings.showEnergySaferWarningDialog +import de.cyface.energy_settings.TrackingSettings.showGnssWarningDialog +import de.cyface.energy_settings.TrackingSettings.showProblematicManufacturerDialog +import de.cyface.energy_settings.TrackingSettings.showRestrictedBackgroundProcessingWarningDialog +import de.cyface.persistence.model.ParcelableGeoLocation +import de.cyface.synchronization.OAuth2 +import de.cyface.synchronization.OAuth2.Companion.END_SESSION_REQUEST_CODE +import de.cyface.synchronization.WiFiSurveyor +import de.cyface.uploader.exception.SynchronisationException +import de.cyface.utils.AppPreferences +import de.cyface.utils.DiskConsumption +import de.cyface.utils.Validate +import io.sentry.Sentry +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.TokenResponse +import java.io.IOException +import java.lang.ref.WeakReference + +/** + * The base `Activity` for the actual Cyface measurement client. It's called by the [TermsOfUseActivity] + * class. + * + * It calls the [de.cyface.app.auth.LoginActivity] if the user is unauthorized and uses the + * outcome of the OAuth 2 authorization flow to negotiate the final authorized state. This is done + * by performing the "authorization code exchange" if required. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 4.0.0 + * @since 1.0.0 + */ +class MainActivity : AppCompatActivity(), ServiceProvider, CameraServiceProvider { + + /** + * The generated class which holds all bindings from the layout file. + */ + private lateinit var binding: ActivityMainBinding + + /** + * The `DataCapturingService` which represents the API of the Cyface Android SDK. + */ + override lateinit var capturing: CyfaceDataCapturingService + + /** + * The `CameraService` which collects camera data if the user did activate this feature. + */ + override lateinit var cameraService: CameraService + + /** + * The controller which allows to navigate through the navigation graph. + */ + private lateinit var navigation: NavController + + /** + * The `SharedPreferences` used to store the user's preferences. + */ + private lateinit var preferences: AppPreferences + + /** + * The `SharedPreferences` used to store the camera settings. + */ + private lateinit var cameraPreferences: CameraPreferences + + /** + * The authorization. + */ + override lateinit var auth: OAuth2 + + /** + * Instead of registering the `DataCapturingButton/CapturingFragment` here, the `CapturingFragment` + * just registers and unregisters itself. + */ + private val unInterestedListener: DataCapturingListener = object : DataCapturingListener { + override fun onFixAcquired() {} + override fun onFixLost() {} + override fun onNewGeoLocationAcquired(position: ParcelableGeoLocation) {} + override fun onNewSensorDataAcquired(data: CapturedData) {} + override fun onLowDiskSpace(allocation: DiskConsumption) {} + override fun onSynchronizationSuccessful() {} + override fun onErrorState(e: Exception) {} + override fun onRequiresPermission(permission: String, reason: Reason): Boolean { + return false + } + + override fun onCapturingStopped() {} + } + + private val unInterestedCameraListener: CameraListener = object : CameraListener { + override fun onNewPictureAcquired(picturesCaptured: Int) {} + override fun onNewVideoStarted() {} + override fun onVideoStopped() {} + override fun onLowDiskSpace(allocation: DiskConsumption) {} + override fun onErrorState(e: Exception) {} + override fun onRequiresPermission(permission: String, reason: Reason): Boolean { + return false + } + + override fun onCapturingStopped() {} + } + + override fun onCreate(savedInstanceState: Bundle?) { + preferences = AppPreferences(this) + cameraPreferences = CameraPreferences(this) + + // Location permissions are requested by MainFragment which needs to react to results + + // If camera service is requested, check needed permissions + val cameraEnabled = cameraPreferences.getCameraEnabled() + val permissionsMissing = ContextCompat.checkSelfPermission( + this, + Manifest.permission.CAMERA + ) != PackageManager.PERMISSION_GRANTED /* + * No permissions needed as we write the data to the app specific directory. + * || ContextCompat.checkSelfPermission(this, + * Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED + * || ContextCompat.checkSelfPermission(this, + * Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED + */ + if (cameraEnabled && permissionsMissing) { + ActivityCompat.requestPermissions( + this, arrayOf( + Manifest.permission.CAMERA /* + * , Manifest.permission.WRITE_EXTERNAL_STORAGE, + * Manifest.permission.READ_EXTERNAL_STORAGE + */ + ), + de.cyface.camera_service.Constants.PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION + ) + } + + // Start DataCapturingService and CameraService + try { + capturing = CyfaceDataCapturingService( + this.applicationContext, + AUTHORITY, + ACCOUNT_TYPE, + BuildConfig.cyfaceServer, + OAuth2.Companion.oauthConfig(BuildConfig.oauthRedirect, BuildConfig.oauthDiscovery), + DataCapturingEventHandler(), + unInterestedListener, // here was the capturing button but it registers itself, too + preferences.getSensorFrequency() + ) + // Needs to be called after new CyfaceDataCapturingService() for the SDK to check and throw + // a specific exception when the LOGIN_ACTIVITY was not set from the SDK using app. + // startSynchronization() // This is done in onAuthorized() instead + // TODO: dataCapturingService!!.addConnectionStatusListener(this) + cameraService = CameraService( + this.applicationContext, + CameraEventHandler(), + unInterestedCameraListener, // here was the capturing button but it registers itself, too + ) + } catch (e: SetupException) { + throw IllegalStateException(e) + } + + // Authorization + auth = OAuth2(applicationContext) + + /****************************************************************************************/ + // Crashes with RuntimeException: `capturing`/`auth` not initialized when this is above + // `capturing=` or `auth=` [RFR-618]. + super.onCreate(savedInstanceState) + + // To access the `Activity`s' `capturingService` from `Fragment.onCreate` the + // `capturingService` has to be initialized before calling `inflate` + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Setting up top action bar & bottom menu (no sidebar, see RFR-333] + // Not using `findNavController()` as `FragmentContainerView` in `activity_main.xml` does not + // work with with `findNavController()` (https://stackoverflow.com/a/60434988/5815054). + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navigation = navHostFragment.navController + binding.bottomNav.setupWithNavController(navigation) + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + setupActionBarWithNavController( + navigation, AppBarConfiguration( + // Adding top-level destinations so they are added to the back stack while navigating + setOf( + de.cyface.app.utils.R.id.navigation_trips, + R.id.navigation_capturing/*, + R.id.navigation_statistics*/ + ) + ) + ) + + // Not showing manufacturer warning on each resume to increase likelihood that it's read + showProblematicManufacturerDialog(this, false, Constants.SUPPORT_EMAIL) + } + + override fun onStart() { + super.onStart() + + // All good, user is authorized + if (auth.isAuthorized()) { + onAuthorized("onStart") + return + } + + // the stored AuthState is incomplete, so check if we are currently receiving the result of + // the authorization flow from the browser. + val response = AuthorizationResponse.fromIntent(intent) + val ex = AuthorizationException.fromIntent(intent) + if (response != null || ex != null) { + auth.updateAfterAuthorization(response, ex) + } + if (response?.authorizationCode != null) { + // authorization code exchange is required + auth.updateAfterAuthorization(response, ex) + exchangeAuthorizationCode(response) + } else if (ex != null) { + onUnauthorized("Auth flow failed: " + ex.message) + } else { + // The user is not logged in / logged out -> LoginActivity is called + onUnauthorized("No auth state retained - re-auth required", false) + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Authorization + if (requestCode == END_SESSION_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + Handler().postDelayed({ signOut(true); finish() }, 2000) + } else { + show("Sign out canceled") + } + } + + /** + * Fixes "home" (back) button in the top action bar when in the fragment details fragment. + */ + override fun onSupportNavigateUp(): Boolean { + return navigation.navigateUp() || super.onSupportNavigateUp() + } + + override fun onResume() { + showGnssWarningDialog(this) + showEnergySaferWarningDialog(this) + showRestrictedBackgroundProcessingWarningDialog(this) + super.onResume() + } + + override fun onDestroy() { + super.onDestroy() + + // Clean up CyfaceDataCapturingService + try { + // As the WifiSurveyor WiFiSurveyor.startSurveillance() tells us to + capturing.shutdownDataCapturingService() + // Before we only called: shutdownConnectionStatusReceiver(); + } catch (e: SynchronisationException) { + if (preferences.getReportingAccepted()) { + Sentry.captureException(e) + } + Log.w(TAG, "Failed to shut down CyfaceDataCapturingService. ", e) + } + + // Authorization + auth.dispose() + } + + /** + * Starts the synchronization. + * + * Creates an [Account] if there is none as an account is required + * for synchronization. If there was no account the synchronization is started when the async account + * creation future returns to ensure the account is available at that point. + */ + fun startSynchronization() { + val accountManager = AccountManager.get(this.applicationContext) + val validAccountExists = accountWithTokenExists(accountManager) + if (validAccountExists) { + try { + Log.d(TAG, "startSynchronization: Starting WifiSurveyor with exiting account.") + capturing.startWifiSurveyor() + } catch (e: SetupException) { + throw IllegalStateException(e) + } + return + } + + // The LoginActivity is called by Android which handles the account creation (authentication) + Log.d(TAG, "startSynchronization: No validAccountExists, requesting LoginActivity") + accountManager.addAccount( + ACCOUNT_TYPE, + de.cyface.synchronization.Constants.AUTH_TOKEN_TYPE, + null, + null, + this, + { future: AccountManagerFuture -> + val accountManager1 = AccountManager.get(this.applicationContext) + try { + // allows to detect when LoginActivity is closed + future.result + + // The LoginActivity created a temporary account which cannot be used for synchronization. + // As the login was successful we now register the account correctly: + val account = accountManager1.getAccountsByType(ACCOUNT_TYPE)[0] + Validate.notNull(account) + + // Set synchronizationEnabled to the current user preferences + val syncEnabledPreference = preferences.getUpload() + Log.d( + WiFiSurveyor.TAG, + "Setting syncEnabled for new account to preference: $syncEnabledPreference" + ) + capturing.wiFiSurveyor.makeAccountSyncable( + account, + syncEnabledPreference + ) + Log.d(TAG, "Starting WifiSurveyor with new account.") + capturing.startWifiSurveyor() + } catch (e: OperationCanceledException) { + // Remove temp account when LoginActivity is closed during login [CY-5087] + val accounts = accountManager1.getAccountsByType(ACCOUNT_TYPE) + if (accounts.isNotEmpty()) { + val account = accounts[0] + accountManager1.removeAccount(account, null, null) + } + // This closes the app when the LoginActivity is closed + this.finish() + } catch (e: AuthenticatorException) { + throw IllegalStateException(e) + } catch (e: IOException) { + throw IllegalStateException(e) + } catch (e: SetupException) { + throw IllegalStateException(e) + } + }, + null + ) + } + + @MainThread + private fun onUnauthorized(explanation: String, explain: Boolean = true) { + runOnUiThread { + if (explain) { + show("unauthorized, logging out ... ($explanation)") + Handler().postDelayed({ signOut(false) }, 2000) + } else { + signOut(false) + } + } + } + + @MainThread + private fun onAuthorized(message: String) { + runOnUiThread { + Log.d(TAG, "authorized ($message)") + startSynchronization() + } + } + + @MainThread + private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { + Log.d(TAG, "Exchanging authorization code") + val requestSuccessful = auth.performTokenRequest( + authorizationResponse.createTokenExchangeRequest() + ) { tokenResponse: TokenResponse?, authException: AuthorizationException? -> + val authSuccessful = auth.handleCodeExchangeResponse( + tokenResponse, + authException, + ACCOUNT_TYPE, + applicationContext, + AUTHORITY + ) + if (authSuccessful) { + onAuthorized("code exchanged, account updated") + } else { + onUnauthorized( + ("Authorization Code exchange failed" + + if (authException != null) authException.error else "") + ) + } + } + if (!requestSuccessful) { + onUnauthorized("Client authentication method is unsupported") + } + } + + /** + * When the user explicitly wants to sign out, we need to send an `endSession` request to the + * auth server, see `MenuProvider.logout`. + * + * Here we only want to clear the local data about the current session, when something changes. + */ + @MainThread + private fun signOut(removeAccount: Boolean = false) { + auth.signOut() + + // E.g. `MainActivity.onStart()` calls `signOut()` when the user is already signed out + // so there is no account to be removed. + if (removeAccount) { + // Also remove account from account manager + capturing.removeAccount(capturing.wiFiSurveyor.account.name) + } + + val mainIntent = Intent(this, LoginActivity::class.java) + mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(mainIntent) + finish() + } + + @MainThread + private fun show(message: String) { + Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() + } + + /** + * Handles incoming inter process communication messages from services used by this application. + * This is required to update the UI based on changes within those services (e.g. status). + * - The Handler code runs all on the same (e.g. UI) thread. + * - We don't use Broadcasts here to reduce the amount of broadcasts. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 1.0.2 + * @since 1.0.0 + * @property context The context [MainActivity] for this message handler. + */ + private class IncomingMessageHandler(context: MainActivity) : + Handler(context.mainLooper) { + /** + * A weak reference to the context activity this handler handles messages for. The weak reference is + * necessary since the lifetime of the handler might be longer than the activity's and a normal reference would + * hinder the garbage collector to destroy the activity in that instance. + */ + private val context: WeakReference + + init { + this.context = WeakReference(context) + } + + override fun handleMessage(msg: Message) { + val activity = context.get() + ?: // noinspection UnnecessaryReturnStatement + return + /* + * switch (msg.what) { + * case Message.WARNING_SPACE: + * Log.d(TAG, "received MESSAGE about WARNING SPACE: Unbinding services !"); + * activity.unbindDataCapturingService(); + * final MainFragment mainFragment = (MainFragment)activity.getFragmentManager() + * .findFragmentByTag(MAIN_FRAGMENT_TAG); + * if (mainFragment != null) { // the fragment was switch just now this one can be null + * mainFragment.dataCapturingButton.setDeactivated(); + * } + * break; + * default: + * super.handleMessage(msg); + * } + */ + } + } + + companion object { + /** + * The tag used to identify logging messages send to logcat. + */ + const val TAG = Constants.PACKAGE + + /** + * Checks if there is an account with an authToken. + * + * @param accountManager A reference to the [AccountManager] + * @return true if there is an account with an authToken + * @throws RuntimeException if there is more than one account + */ + @JvmStatic + fun accountWithTokenExists(accountManager: AccountManager): Boolean { + val existingAccounts = accountManager.getAccountsByType(ACCOUNT_TYPE) + Validate.isTrue(existingAccounts.size < 2, "More than one account exists.") + return existingAccounts.isNotEmpty() + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/MeasuringClient.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/MeasuringClient.kt new file mode 100644 index 00000000..f45f5cb5 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/MeasuringClient.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural + +import android.app.Application +import android.content.IntentFilter +import android.widget.Toast +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import de.cyface.app.digural.auth.LoginActivity +import de.cyface.synchronization.CyfaceAuthenticator +import de.cyface.synchronization.ErrorHandler +import de.cyface.synchronization.ErrorHandler.ErrorCode +import de.cyface.utils.AppPreferences +import io.sentry.Sentry + +/** + * The implementation of Android's `Application` class for this project. + * + * It shows the errors (not handled by [LoginActivity]) as simple [Toast] to the user. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 1.5.1 + * @since 1.0.0 + */ +class MeasuringClient : Application() { + /** + * Stores the user's preferences. + */ + private lateinit var preferences: AppPreferences + + /** + * Reports error events to the user via UI and to Sentry, if opted-in. + */ + private val errorListener = + ErrorHandler.ErrorListener { errorCode: ErrorCode, errorMessage: String -> + val appName = applicationContext.getString(R.string.app_name) + Toast.makeText( + this@MeasuringClient, + String.format("%s - %s", errorMessage, appName), + Toast.LENGTH_LONG + ).show() + + // There are two cases we can have network errors + // 1. during authentication (AuthTokenRequest), either login or before upload + // 2. during upload (SyncPerformer/SyncAdapter) + // In the first case we get the full stacktrace by a Sentry capture in AuthTokenRequest + // but in the second case we cannot get the stacktrace as it's only available in the SDK. + // For that reason we also capture a message here. + // However, it seems like e.g. a interrupted upload shows a toast but does not trigger sentry. + if (preferences.getReportingAccepted()) { + Sentry.captureMessage(errorCode.name + ": " + errorMessage) + } + } + + override fun onCreate() { + super.onCreate() + preferences = AppPreferences(this) + + // Register the activity to be called by the authenticator to request credentials from the user. + CyfaceAuthenticator.LOGIN_ACTIVITY = LoginActivity::class.java + + // Register error listener + errorHandler = ErrorHandler() + LocalBroadcastManager.getInstance(this).registerReceiver( + errorHandler!!, + IntentFilter(ErrorHandler.ERROR_INTENT) + ) + errorHandler!!.addListener(errorListener) + } + + override fun onTerminate() { + errorHandler!!.removeListener(errorListener) + LocalBroadcastManager.getInstance(this).unregisterReceiver( + errorHandler!! + ) + super.onTerminate() + } + + companion object { + /** + * Allows to subscribe to error events. + */ + @JvmStatic + var errorHandler: ErrorHandler? = null + private set + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/TermsOfUseActivity.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/TermsOfUseActivity.kt new file mode 100644 index 00000000..b9b03990 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/TermsOfUseActivity.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.CompoundButton +import de.cyface.utils.AppPreferences + +/** + * The TermsOfUserActivity is the first [Activity] started on app launch. + * + * It's responsible for informing the user about the terms of use (and data privacy conditions). + * + * When the current terms are accepted or have been before, the [MainActivity] is launched. + * + * @author Armin Schnabel + * @version 1.1.1 + * @since 1.0.0 + */ +class TermsOfUseActivity : Activity(), View.OnClickListener { + /** + * Intent for switching to the main activity after this activity has been finished. + */ + private var callMainActivityIntent: Intent? = null + + /** + * The button to click to accept the terms of use and data privacy conditions. + */ + private var acceptTermsButton: Button? = null + + /** + * `True` if the user opted-in to error reporting. + */ + private var isReportingEnabled = false + + /** + * To check whether the user accepted the terms and opted-in to error reporting. + */ + private lateinit var preferences: AppPreferences + + /** + * Allows the user to opt-in to error reporting. + */ + private var acceptReportsCheckbox: CheckBox? = null + + /** + * To ask the user to accept the terms. + */ + private var acceptTermsCheckbox: CheckBox? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferences = AppPreferences(applicationContext) + callMainActivityIntent = Intent(this, MainActivity::class.java) + if (currentTermsHadBeenAccepted()) { + startActivity(callMainActivityIntent) + finish() + return + } + setContentView(R.layout.activity_terms_of_use) + } + + /** + * @return `True` if the latest privacy policy was accepted by the user. + */ + private fun currentTermsHadBeenAccepted(): Boolean { + return preferences.getAcceptedTerms() == BuildConfig.currentTerms + } + + /** + * Registers the handlers for user interaction. + */ + private fun registerOnClickListeners() { + acceptTermsButton = findViewById(R.id.accept_terms_button) + acceptTermsButton!!.setOnClickListener(this) + acceptTermsCheckbox = findViewById(R.id.accept_terms_checkbox) + acceptReportsCheckbox = findViewById(R.id.accept_reports_checkbox) + acceptTermsCheckbox!! + .setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + acceptTermsButton!!.isEnabled = isChecked + } + acceptReportsCheckbox!! + .setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + isReportingEnabled = isChecked + } + } + + /** + * Unregisters the handlers for user interaction. + */ + private fun unregisterOnClickListeners() { + acceptTermsButton!!.setOnClickListener(null) + acceptTermsCheckbox!!.setOnCheckedChangeListener(null) + acceptReportsCheckbox!!.setOnCheckedChangeListener(null) + } + + override fun onClick(view: View) { + preferences.saveAcceptedTerms(BuildConfig.currentTerms) + preferences.saveReportingAccepted(isReportingEnabled) + this.startActivity(callMainActivityIntent) + finish() + } + + override fun onPause() { + super.onPause() + unregisterOnClickListeners() + acceptTermsButton!!.setOnClickListener(null) + } + + public override fun onResume() { + registerOnClickListeners() + super.onResume() + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/auth/LoginActivity.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/auth/LoginActivity.kt new file mode 100644 index 00000000..6c1892dc --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/auth/LoginActivity.kt @@ -0,0 +1,439 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.auth + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.View.VISIBLE +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.AnyThread +import androidx.annotation.ColorRes +import androidx.annotation.MainThread +import androidx.annotation.WorkerThread +import androidx.appcompat.app.AppCompatActivity +import androidx.browser.customtabs.CustomTabsIntent +import de.cyface.app.digural.MainActivity +import de.cyface.app.digural.R +import de.cyface.synchronization.AuthStateManager +import de.cyface.synchronization.Configuration +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ClientSecretBasic +import net.openid.appauth.RegistrationRequest +import net.openid.appauth.RegistrationResponse +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.browser.AnyBrowserMatcher +import net.openid.appauth.browser.BrowserMatcher +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +/** + * A login screen that offers login via email/password. + * + * *ATTENTION*: + * The browser page opened by this activity stops working on the emulator after some time. + * Use a real device to test the auth workflow for now. + * + * @author Armin Schnabel + * @version 4.0.0 + * @since 1.0.0 + */ +class LoginActivity : AppCompatActivity() { + + private val TAG = "de.cyface.app.digural.login" + private val EXTRA_FAILED = "failed" + private val RC_AUTH = 100 + + private var mAuthService: AuthorizationService? = null + private lateinit var mAuthStateManager: AuthStateManager + private lateinit var mConfiguration: Configuration + + private val mClientId = AtomicReference() + private val mAuthRequest = AtomicReference() + private val mAuthIntent = AtomicReference() + private var mAuthIntentLatch = CountDownLatch(1) + private lateinit var mExecutor: ExecutorService + + private var mUsePendingIntents = false + + private var mBrowserMatcher: BrowserMatcher = AnyBrowserMatcher.INSTANCE + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mExecutor = Executors.newSingleThreadExecutor() + mAuthStateManager = AuthStateManager.getInstance(this) + mConfiguration = Configuration.getInstance(this) + + // Already authorized + if (mAuthStateManager.current.isAuthorized + && !mConfiguration.hasConfigurationChanged() + ) { + // As the LoginActivity should only be shown when the user is not authenticated + // we can't forward to `MainActivity` just like that as this would lead to a loop. + //Log.i(TAG, "User is already authenticated, processing to token activity") + Log.e(TAG, "User is already authenticated") + show("User is already authenticated") + finish() + return + } + + setContentView(R.layout.activity_login) + findViewById(R.id.retry).setOnClickListener { + mExecutor.submit { initializeAppAuth() } + } + findViewById(R.id.start_auth).setOnClickListener { startAuth() } + if (!mConfiguration.isValid) { + displayError(mConfiguration.configurationError!!, false) + return + } + if (mConfiguration.hasConfigurationChanged()) { + // discard any existing authorization state due to the change of configuration + Log.i(TAG, "Configuration change detected, discarding old state") + mAuthStateManager.replace(AuthState()) + mConfiguration.acceptConfiguration() + } + if (intent.getBooleanExtra(EXTRA_FAILED, false)) { + show("Authorization canceled") + } + displayLoading(getString(de.cyface.app.utils.R.string.initializing)) + mExecutor.submit { initializeAppAuth() } + } + + override fun onStart() { + super.onStart() + if (mExecutor.isShutdown) { + mExecutor = Executors.newSingleThreadExecutor() + } + } + + override fun onStop() { + super.onStop() + mExecutor.shutdownNow() + } + + override fun onDestroy() { + super.onDestroy() + if (mAuthService != null) { + mAuthService!!.dispose() + } + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + displayAuthOptions() + if (resultCode == RESULT_CANCELED) { + show("Authorization canceled") + } else { + show("Logging in ...") + //Toast.makeText(applicationContext, "Logging in ...", Toast.LENGTH_SHORT).show() + val intent = Intent(this, MainActivity::class.java) + intent.putExtras(data!!.extras!!) + startActivity(intent) + finish() // added because of our workflow where MainActivity calls LoginActivity + } + } + + @MainThread + fun startAuth() { + displayLoading(getString(de.cyface.app.utils.R.string.making_authorization_request)) + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + mExecutor.submit { doAuth() } + } + + /** + * Initializes the authorization service configuration if necessary, either from the local + * static values or by retrieving an OpenID discovery document. + */ + @WorkerThread + private fun initializeAppAuth() { + Log.i(TAG, "Initializing AppAuth") + recreateAuthorizationService() + if (mAuthStateManager.current.authorizationServiceConfiguration != null) { + // configuration is already created, skip to client initialization + Log.i(TAG, "auth config already established") + initializeClient() + return + } + + // if we are not using discovery, build the authorization service configuration directly + // from the static configuration values. + if (mConfiguration.discoveryUri == null) { + Log.i(TAG, "Creating auth config") + val config = AuthorizationServiceConfiguration( + mConfiguration.authEndpointUri!!, + mConfiguration.tokenEndpointUri!!, + mConfiguration.registrationEndpointUri, + mConfiguration.endSessionEndpoint + ) + mAuthStateManager.replace(AuthState(config)) + initializeClient() + return + } + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + runOnUiThread { displayLoading(getString(de.cyface.app.utils.R.string.retrieving_discovery_document)) } + Log.i(TAG, "Retrieving OpenID discovery doc") + AuthorizationServiceConfiguration.fetchFromUrl( + mConfiguration.discoveryUri!!, + { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? -> + handleConfigurationRetrievalResult( + config, + ex + ) + }, + mConfiguration.connectionBuilder + ) + } + + @MainThread + private fun handleConfigurationRetrievalResult( + config: AuthorizationServiceConfiguration?, + ex: AuthorizationException? + ) { + if (config == null) { + Log.i(TAG, "Failed to retrieve discovery document", ex) + val message = if (ex!!.message == "Network error") "Offline?" else ex.message + displayError( + getString( + de.cyface.app.utils.R.string.failed_to_retrieve_discovery, + message + ), true + ) + return + } + Log.i(TAG, "Discovery document retrieved") + mAuthStateManager.replace(AuthState(config)) + mExecutor.submit { initializeClient() } + } + + /** + * Initiates a dynamic registration request if a client ID is not provided by the static + * configuration. + */ + @WorkerThread + private fun initializeClient() { + if (mConfiguration.clientId != null) { + Log.i(TAG, "Using static client ID: " + mConfiguration.clientId) + // use a statically configured client ID + mClientId.set(mConfiguration.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + val lastResponse = mAuthStateManager.current.lastRegistrationResponse + if (lastResponse != null) { + Log.i(TAG, "Using dynamic client ID: " + lastResponse.clientId) + // already dynamically registered a client ID + mClientId.set(lastResponse.clientId) + runOnUiThread { initializeAuthRequest() } + return + } + + // WrongThread inference is incorrect for lambdas + // noinspection WrongThread + runOnUiThread { displayLoading(getString(de.cyface.app.utils.R.string.dynamically_registering_client)) } + Log.i(TAG, "Dynamically registering client") + val registrationRequest = RegistrationRequest.Builder( + mAuthStateManager.current.authorizationServiceConfiguration!!, + listOf(mConfiguration.redirectUri) + ) + .setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME) + .build() + mAuthService!!.performRegistrationRequest( + registrationRequest + ) { response: RegistrationResponse?, ex: AuthorizationException? -> + handleRegistrationResponse( + response, + ex + ) + } + } + + @MainThread + private fun handleRegistrationResponse( + response: RegistrationResponse?, + ex: AuthorizationException? + ) { + mAuthStateManager.updateAfterRegistration(response, ex) + if (response == null) { + Log.i(TAG, "Failed to dynamically register client", ex) + displayErrorLater( + getString( + de.cyface.app.utils.R.string.failed_to_register_client, + ex!!.message + ), true + ) + return + } + Log.i(TAG, "Dynamically registered client: " + response.clientId) + mClientId.set(response.clientId) + initializeAuthRequest() + } + + /** + * Performs the authorization request, using the browser selected in the spinner, + * and a user-provided `login_hint` if available. + */ + @WorkerThread + private fun doAuth() { + try { + mAuthIntentLatch.await() + } catch (ex: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for auth intent") + } + if (mUsePendingIntents) { // We currently always use the other option below + val completionIntent = Intent(this, MainActivity::class.java) + val cancelIntent = Intent(this, LoginActivity::class.java) + cancelIntent.putExtra(EXTRA_FAILED, true) + cancelIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + var flags = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + mAuthService!!.performAuthorizationRequest( + mAuthRequest.get()!!, + PendingIntent.getActivity(this, 0, completionIntent, flags), + PendingIntent.getActivity(this, 0, cancelIntent, flags), + mAuthIntent.get()!! + ) + } else { + val intent = mAuthService!!.getAuthorizationRequestIntent( + mAuthRequest.get()!!, + mAuthIntent.get()!! + ) + startActivityForResult(intent, RC_AUTH) + } + } + + private fun recreateAuthorizationService() { + if (mAuthService != null) { + Log.i(TAG, "Discarding existing AuthService instance") + mAuthService!!.dispose() + } + mAuthService = createAuthorizationService() + mAuthRequest.set(null) + mAuthIntent.set(null) + } + + private fun createAuthorizationService(): AuthorizationService { + Log.i(TAG, "Creating authorization service") + val builder = AppAuthConfiguration.Builder() + builder.setBrowserMatcher(mBrowserMatcher) + builder.setConnectionBuilder(mConfiguration.connectionBuilder) + return AuthorizationService(this, builder.build()) + } + + @MainThread + private fun displayLoading(loadingMessage: String) { + findViewById(R.id.loading_container).visibility = VISIBLE + findViewById(R.id.auth_container).visibility = View.GONE + findViewById(R.id.error_container).visibility = View.GONE + (findViewById(R.id.loading_description) as TextView).text = + loadingMessage + } + + @MainThread + private fun displayError(error: String, recoverable: Boolean) { + findViewById(R.id.error_container).visibility = VISIBLE + findViewById(R.id.loading_container).visibility = View.GONE + findViewById(R.id.auth_container).visibility = View.GONE + (findViewById(R.id.error_description) as TextView).text = error + findViewById(R.id.retry).visibility = if (recoverable) VISIBLE else View.GONE + } + + // WrongThread inference is incorrect in this case + @AnyThread + private fun displayErrorLater( + error: String, + @Suppress("SameParameterValue") recoverable: Boolean + ) { + runOnUiThread { displayError(error, recoverable) } + } + + @MainThread + private fun initializeAuthRequest() { + createAuthRequest(this@LoginActivity) + warmUpBrowser(this@LoginActivity) + displayAuthOptions() + } + + @MainThread + private fun displayAuthOptions() { + findViewById(R.id.auth_container).visibility = VISIBLE + findViewById(R.id.loading_container).visibility = View.GONE + findViewById(R.id.error_container).visibility = View.GONE + } + + @Suppress("SameParameterValue") + @MainThread + private fun show(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + @TargetApi(Build.VERSION_CODES.M) + private fun getColorCompat(@ColorRes color: Int): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + getColor(color) + } else { + resources.getColor(color) + } + } + + companion object { + private fun warmUpBrowser(loginActivity: LoginActivity) { + loginActivity.mAuthIntentLatch = CountDownLatch(1) + loginActivity.mExecutor.execute { + Log.i(loginActivity.TAG, "Warming up browser instance for auth request") + val intentBuilder = loginActivity.mAuthService!!.createCustomTabsIntentBuilder( + loginActivity.mAuthRequest.get()!!.toUri() + ) + intentBuilder.setToolbarColor(loginActivity.getColorCompat(de.cyface.app.utils.R.color.defaultPrimary)) + loginActivity.mAuthIntent.set(intentBuilder.build()) + loginActivity.mAuthIntentLatch.countDown() + } + } + + private fun createAuthRequest(loginActivity: LoginActivity) { + Log.i(loginActivity.TAG, "Creating auth request") + val authRequestBuilder = AuthorizationRequest.Builder( + loginActivity.mAuthStateManager.current.authorizationServiceConfiguration!!, + loginActivity.mClientId.get(), + ResponseTypeValues.CODE, + loginActivity.mConfiguration.redirectUri + ) + .setScope(loginActivity.mConfiguration.scope) + loginActivity.mAuthRequest.set(authRequestBuilder.build()) + } + } +} diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/button/AbstractButton.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/AbstractButton.kt new file mode 100644 index 00000000..7a636be0 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/AbstractButton.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2017 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.button + +import android.view.View +import android.widget.ImageButton +import com.github.lzyzsd.circleprogress.DonutProgress + +/** + * Interface for `onClickListener` for buttons as used by the Cyface main fragment. + * + * @author Klemens Muthmann + * @version 1.0.2 + * @since 1.0.0 + */ +interface AbstractButton : View.OnClickListener { + /** + * This method should be called each time the view containing this button is destroyed. Usually this happens as part + * of the `Fragment#onDestroyView()` method. + */ + fun onCreateView(button: ImageButton?, progress: DonutProgress?) + + /** + * This method should be called each time the view containing this button is destroyed. Usually this happens as part + * of the `Fragment#onDestroyView()` method. + */ + fun onDestroyView() + fun addButtonListener(buttonListener: ButtonListener?) +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/button/ButtonListener.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/ButtonListener.kt new file mode 100644 index 00000000..08d75389 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/ButtonListener.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2017 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.button + +/** + * Listener interface for events happening to a button. + * + * @author Klemens Muthmann + * @version 1.0.2 + * @since 1.0.0 + */ +interface ButtonListener { + /** + * This method is invoked every time the button has been pressed. + * + * @param button The pressed button. + */ + fun onClick(button: AbstractButton?) +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/button/SynchronizationButton.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/SynchronizationButton.kt new file mode 100644 index 00000000..16651d92 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/button/SynchronizationButton.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.button + +import android.accounts.AccountManager +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.ImageButton +import android.widget.Toast +import com.github.lzyzsd.circleprogress.DonutProgress +import de.cyface.app.digural.MainActivity.Companion.accountWithTokenExists +import de.cyface.app.digural.utils.Constants.TAG +import de.cyface.app.utils.R +import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.synchronization.WiFiSurveyor +import de.cyface.utils.AppPreferences +import de.cyface.utils.Validate + +/** + * A button listener for the button to trigger data sync and show its progress + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 2.3.5 + * @since 1.0.0 + */ +class SynchronizationButton(dataCapturingService: CyfaceDataCapturingService) : AbstractButton { + // TODO [CY-3855]: communication with MainFragment/Activity should use this listener instead of hard-coded + // implementation + private val listener: MutableCollection + private var isActivated = false + var context: Context? = null + private set + + /** + * The actual java button object, this class implements behaviour for. + */ + private var button: ImageButton? = null + private var progressView: DonutProgress? = null + private lateinit var preferences: AppPreferences + + /** + * [CyfaceDataCapturingService] to check [WiFiSurveyor.isConnected] + */ + private val dataCapturingService: CyfaceDataCapturingService + + init { + listener = HashSet() + this.dataCapturingService = dataCapturingService + } + + override fun onCreateView(button: ImageButton?, progress: DonutProgress?) { + Validate.notNull(button) + context = button!!.context + this.button = button + this.progressView = progress + preferences = AppPreferences(context!!) + onResume() // TODO[MOV-621] the parent's onResume, thus, this class's onResume should automatically be called + button.setOnClickListener(this) + } + + /** + * Method to be called in the parent's Android life-cycle onResume method to make sure the sync button is in the + * right state as the sync status might have changed while the view was paused. + * + * TODO [CY-3857]: We removed the preferences flags for the sync state. To enable a synchronized check + * for the sync state we need to implement a hook for this in the SDK + */ + fun onResume() { + updateSyncButton() + } + + /** + * The ContentResolver.isSyncActive does not work as expected on Nexus 5X when ~1,2GB of data is left to be synced. + * It returns false before the sync is finished (often directly after starting the sync while it still runs in the + * background). + * The same issue is reported in the following: + * http://porcupineprogrammer.blogspot.de/2013/01/android-sync-adapter-lifecycle.html + * http://stackoverflow.com/a/13179369/5815054 (their solution didn't work) + * For that reason we have to use a preference-flag to check the sync status / progress + */ + fun isSynchronizingChanged(newSyncState: Boolean) { + if (newSyncState && !isActivated) setActivated() + if (!newSyncState && isActivated) setDeactivated() + } + + private fun setActivated() { + button!!.visibility = View.INVISIBLE + progressView!!.visibility = View.VISIBLE + isActivated = true + } + + private fun setDeactivated() { + button!!.visibility = View.VISIBLE + progressView!!.visibility = View.INVISIBLE + progressView!!.progress = 0 // Or else last progress is shown upon restart + isActivated = false + } + + fun updateProgress(percent: Float) { + progressView!!.progress = percent.toInt() + } + + override fun onClick(view: View) { + val context = view.context + if (isActivated) { + Log.w(TAG, "Data Sync button is out of sync.") + } + + // We want to show the current state no matter if we actually perform a new sync or not + updateSyncButton() + + // Check if syncable network is available + val isConnected = dataCapturingService.wiFiSurveyor.isConnected + Log.v(WiFiSurveyor.TAG, (if (isConnected) "" else "Not ") + "connected to syncable network") + if (!isConnected) { + Toast.makeText( + context, + context.getString(R.string.error_message_sync_canceled_no_wifi), + Toast.LENGTH_SHORT + ) + .show() + return + } + + // Check is sync is disabled via frontend + val syncEnabled = dataCapturingService.wiFiSurveyor.isSyncEnabled + val syncPreferenceEnabled = preferences.getUpload() + Validate.isTrue( + syncEnabled == syncPreferenceEnabled, + "sync " + (if (syncEnabled) "enabled" else "disabled") + + " but syncPreference " + if (syncPreferenceEnabled) "enabled" else "disabled" + ) + if (!syncEnabled) { + Toast.makeText( + context, context.getString(R.string.error_message_sync_canceled_disabled), + Toast.LENGTH_SHORT + ).show() + return + } + + // Request instant Synchronization + dataCapturingService.scheduleSyncNow() + } + + /** + * When sync is already serializing (which does not push sync status updates to the UI) but the + * sync was started before the current view was created the sync button would still show that no + * sync is active. Until we make the serialization push progress updates to the ui clicking the button + * should at least change the button to isSyncing + */ + private fun updateSyncButton() { + + // We can only check if periodic sync is enabled when there is already an account + val accountManager = AccountManager.get(context) + val validAccountExists = accountWithTokenExists(accountManager) + if (!validAccountExists) { + Log.d(TAG, "updateSyncButton: No validAccountExists, doing nothing.") + return + } + + // TODO [MOV-621]: this returns false but the serialization may still be running but wifi off + /* + * final boolean connectedToSyncableConnection = dataCapturingService.getWiFiSurveyor().isConnected(); + * Log.d(TAG, "SyncButton was clicked, updating button status to:" + connectedToSyncableConnection); + * isSynchronizingChanged(connectedToSyncableConnection); + */ + } + + override fun onDestroyView() { + button!!.setOnClickListener(null) + } + + override fun addButtonListener(buttonListener: ButtonListener?) { + Validate.notNull(buttonListener) + listener.add(buttonListener!!) + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/ImprintFragment.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/ImprintFragment.kt new file mode 100644 index 00000000..2620c437 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/ImprintFragment.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import de.cyface.app.digural.databinding.FragmentImprintBinding + +/** + * The [Fragment] which shows the imprint information to the user. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.2.0 + */ +class ImprintFragment : Fragment() { + + /** + * This property is only valid between onCreateView and onDestroyView. + */ + private var _binding: FragmentImprintBinding? = null + + /** + * The generated class which holds all bindings from the layout file. + */ + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentImprintBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/MenuProvider.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/MenuProvider.kt new file mode 100644 index 00000000..48b66a2d --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/MenuProvider.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing + +import android.content.Intent +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.navigation.NavController +import de.cyface.app.digural.R +import de.cyface.app.digural.CapturingFragmentDirections +import de.cyface.app.digural.MainActivity +import de.cyface.app.digural.utils.Constants.SUPPORT_EMAIL +import de.cyface.energy_settings.TrackingSettings + +/** + * The [androidx.core.view.MenuProvider] for the [de.cyface.app.CapturingFragment] which defines which + * options are shown in the action bar at the top right. + * + * @author Armin Schnabel + * @version 2.0.0 + * @since 3.2.0 + */ +class MenuProvider( + private val activity: MainActivity, + private val navController: NavController +) : androidx.core.view.MenuProvider { + + /** + * The `Intent` used when the user wants to send feedback. + */ + private lateinit var emailIntent: Intent + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.capturing, menu) + + // Setting up feedback email template + emailIntent = TrackingSettings.generateFeedbackEmailIntent( + activity, + activity.getString(de.cyface.energy_settings.R.string.feedback_error_description), + SUPPORT_EMAIL + ) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.guide_item -> { + if (!TrackingSettings.showGnssWarningDialog(activity) && + !TrackingSettings.showEnergySaferWarningDialog(activity) && + !TrackingSettings.showRestrictedBackgroundProcessingWarningDialog(activity) && + !TrackingSettings.showProblematicManufacturerDialog( + activity, + true, + SUPPORT_EMAIL + ) + ) { + TrackingSettings.showNoGuidanceNeededDialog(activity, SUPPORT_EMAIL) + } + true + } + R.id.feedback_item -> { + activity.startActivity( + Intent.createChooser( + emailIntent, + activity.getString(de.cyface.energy_settings.R.string.feedback_choose_email_app) + ) + ) + true + } + R.id.imprint_item -> { + val action = CapturingFragmentDirections.actionCapturingToImprint() + navController.navigate(action) + true + } + R.id.settings_item -> { + val action = CapturingFragmentDirections.actionCapturingToSettings() + navController.navigate(action) + true + } + /*R.id.logout_item -> { + try { + Toast.makeText(activity.applicationContext, "Logging out ...", Toast.LENGTH_SHORT).show() + // This inform the auth server that the user wants to end its session + activity.auth.endSession(activity) + //signOut() // instead of `endSession()` to sign out softly for testing + activity.capturing.removeAccount(activity.capturing.wiFiSurveyor.account.name) + } catch (e: SynchronisationException) { + throw IllegalStateException(e) + } + // Show login screen + // This is done by MainActivity.onActivityResult -> signOut() + //(activity as MainActivity).startSynchronization() + true + }*/ + else -> { + false + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CameraSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CameraSwitchHandler.kt new file mode 100644 index 00000000..31e5cfd3 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CameraSwitchHandler.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.widget.CompoundButton +import android.widget.Toast +import androidx.core.app.ActivityCompat +import de.cyface.app.digural.R + +/** + * Handles when the user toggles the camera switch. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 2.0.0 + */ +class CameraSwitchHandler( + private val viewModel: SettingsViewModel, + private val fragment: SettingsFragment +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + val context = fragment.requireContext() + if (viewModel.cameraEnabled.value != isChecked) { + if (!isChecked) { + viewModel.setCameraEnabled(false) + } else { + + // No rear camera found to be enabled - we explicitly only support rear camera for now + @SuppressLint("UnsupportedChromeOsCameraSystemFeature") + val noCameraFound = + !context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) + if (noCameraFound) { + buttonView!!.isChecked = false + Toast.makeText(context, R.string.no_camera_available_toast, Toast.LENGTH_LONG) + .show() + return + } + + // Request permission for camera capturing + val permissionsGranted = ActivityCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + if (!permissionsGranted) { + /*ActivityCompat.requestPermissions( + fragment.requireActivity(), + arrayOf(Manifest.permission.CAMERA), + PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION + )*/ + fragment.permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA)) + } else { + // Ask user to select camera mode + fragment.onCameraEnabled(fragment) + + viewModel.setCameraEnabled(true) + } + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CenterMapSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CenterMapSwitchHandler.kt new file mode 100644 index 00000000..d66586ae --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/CenterMapSwitchHandler.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.widget.CompoundButton +import android.widget.Toast +import de.cyface.app.digural.capturing.settings.SettingsViewModel +import de.cyface.app.utils.R + +/** + * Handles when the user toggles the center map switch. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.2.0 + */ +class CenterMapSwitchHandler( + private val viewModel: SettingsViewModel, + private val context: Context? +) : CompoundButton.OnCheckedChangeListener { + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + if (viewModel.centerMap.value != isChecked) { + viewModel.setCenterMap(isChecked) + if (isChecked) { + Toast.makeText( + context, + R.string.zoom_to_location_enabled_toast, + Toast.LENGTH_LONG + ).show() + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/DistanceBasedSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/DistanceBasedSwitchHandler.kt new file mode 100644 index 00000000..d24612d4 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/DistanceBasedSwitchHandler.kt @@ -0,0 +1,48 @@ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.CompoundButton +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.slider.Slider +import de.cyface.app.digural.R +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG + +/** + * Handles UI changes of the 'switcher' used to en-/disable 'distance based triggering' feature. + */ +class DistanceBasedSwitchHandler( + private val context: Context, + private val viewModel: SettingsViewModel, + private val distanceBasedSlider: Slider, + private val distanceBased: TextView, + private val distanceBasedUnit: TextView +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + if (viewModel.distanceBasedTriggering.value != isChecked) { + Log.d(TAG, "Update preference to distance-based-trigger -> $isChecked") + viewModel.setDistanceBasedTriggering(isChecked) + if (isChecked) { + Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG) + .show() + } + + // Update visibility of slider - FIXME: do via observe + val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE + val sliderVisibilityOutOfSync = distanceBasedSlider.visibility != expectedVisibility + val preferenceVisibilityOutOfSync = distanceBased.visibility != expectedVisibility + val unitVisibilityOutOfSync = distanceBasedUnit.visibility != expectedVisibility + if (sliderVisibilityOutOfSync || preferenceVisibilityOutOfSync || unitVisibilityOutOfSync) { + Log.d( + TAG, + "updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" + ) + distanceBasedSlider.visibility = expectedVisibility + distanceBased.visibility = expectedVisibility + distanceBasedUnit.visibility = expectedVisibility + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SensorFrequencySlideHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SensorFrequencySlideHandler.kt new file mode 100644 index 00000000..f51b820a --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SensorFrequencySlideHandler.kt @@ -0,0 +1,25 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.widget.TextView +import com.google.android.material.slider.Slider +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG + +/** + * Handles UI changes of the 'slider' used to adjust the 'sensor frequency' setting. + */ +class SensorFrequencySlideHandler( + private val viewModel: SettingsViewModel, + private val sensorFrequency: TextView +) : Slider.OnChangeListener { + override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { + val newSensorFrequency = newValue.toInt() + if (viewModel.sensorFrequency.value != newSensorFrequency) { + Log.d(TAG, "Update preference to sensor frequency -> $newValue") + viewModel.setSensorFrequency(newSensorFrequency) + + // FIXME: do via observe + sensorFrequency.text = StringBuilder(newSensorFrequency) + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsFragment.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsFragment.kt new file mode 100644 index 00000000..8853865d --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsFragment.kt @@ -0,0 +1,762 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.content.Intent +import android.hardware.camera2.CameraAccessException +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CameraMetadata +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import de.cyface.app.digural.databinding.FragmentSettingsBinding +import de.cyface.app.digural.dialog.ExposureTimeDialog +import de.cyface.app.utils.ServiceProvider +import de.cyface.camera_service.CameraModeDialog +import de.cyface.camera_service.CameraPreferences +import de.cyface.camera_service.Constants +import de.cyface.camera_service.Utils +import de.cyface.datacapturing.CyfaceDataCapturingService +import de.cyface.utils.AppPreferences +import de.cyface.utils.Validate +import java.util.TreeMap +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * The [Fragment] which shows the settings to the user. + * + * @author Armin Schnabel + * @version 2.0.0 + * @since 3.2.0 + */ +class SettingsFragment : Fragment() { + + /** + * This property is only valid between onCreateView and onDestroyView. + */ + private var _binding: FragmentSettingsBinding? = null + + /** + * The generated class which holds all bindings from the layout file. + */ + private val binding get() = _binding!! + + /** + * The capturing service object which controls data capturing and synchronization. + */ + private lateinit var capturing: CyfaceDataCapturingService + + /** + * The [SettingsViewModel] for this fragment. + */ + private lateinit var viewModel: SettingsViewModel + + /** + * Can be launched to request permissions. + */ + var permissionLauncher: ActivityResultLauncher> = + registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + /*val granted = grantResults[0] == PackageManager.PERMISSION_GRANTED + val unexpectedPermissionNumber = grantResults.size < 2 + val missingPermissions = + !(granted && (unexpectedPermissionNumber || (grantResults[1] == PackageManager.PERMISSION_GRANTED)))*/ + + if (result.isNotEmpty()) { + val allGranted = result.values.none { !it } + if (allGranted /*!missingPermissions*/) { + // Ask used which camera mode to use, video or default (shutter image) + onCameraEnabled(this) + } else { + // Deactivate camera service and inform user about this + disabledCamera() + Toast.makeText( + context, + requireContext().getString(de.cyface.camera_service.R.string.camera_service_off_missing_permissions), + Toast.LENGTH_LONG + ).show() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Initialize ViewModel + val appPreferences = AppPreferences(requireContext().applicationContext) + val cameraPreferences = CameraPreferences(requireContext().applicationContext) + viewModel = ViewModelProvider( + this, + SettingsViewModelFactory(appPreferences, cameraPreferences) + )[SettingsViewModel::class.java] + + // Initialize CapturingService + if (activity is ServiceProvider) { + capturing = (activity as ServiceProvider).capturing + } else { + throw RuntimeException("Context does not support the Fragment, implement ServiceProvider") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + + // Register onClick listeners + binding.centerMapSwitch.setOnCheckedChangeListener( + CenterMapSwitchHandler( + viewModel, + context + ) + ) + binding.uploadSwitch.setOnCheckedChangeListener( + UploadSwitchHandler( + viewModel, + context, + capturing + ) + ) + binding.sensorFrequencySlider.addOnChangeListener( + SensorFrequencySlideHandler( + viewModel, + binding.sensorFrequency + ) + ) + /** camera settings **/ + binding.cameraSwitch.setOnCheckedChangeListener( + CameraSwitchHandler( + viewModel, + this + ) + ) + + // Observe view model and update UI + viewModel.centerMap.observe(viewLifecycleOwner) { centerMapValue -> + run { + binding.centerMapSwitch.isChecked = centerMapValue!! + } + } + viewModel.upload.observe(viewLifecycleOwner) { uploadValue -> + run { + binding.uploadSwitch.isChecked = uploadValue!! + } + } + viewModel.sensorFrequency.observe(viewLifecycleOwner) { sensorFrequencyValue -> + run { + Log.d(TAG, "updateView -> sensor frequency slider $sensorFrequencyValue") + binding.sensorFrequency.text = sensorFrequencyValue.toString() + } + } + viewModel.cameraEnabled.observe(viewLifecycleOwner) { cameraEnabledValue -> + run { + binding.cameraSwitch.isChecked = cameraEnabledValue!! + if (cameraEnabledValue) { + updateCameraSettingsView(true) // FIXME: Refactor + + updateDistanceBasedTriggeringViewToPreference() // FIXME: Refactor + + // FIXME: Refactor + // Only check manual sensor settings if it's supported or else the app crashes + if (viewModel.manualSensorSupported) { + updateManualSensorViewToPreferences() + } else { + binding.staticFocusDistanceSlider.visibility = View.INVISIBLE + // staticExposureTimeSlider.setVisibility(View.INVISIBLE); + binding.staticExposureValueSlider.visibility = View.INVISIBLE + } + } else { + updateCameraSettingsView(false) + } + } + } + + // FIXME: Observe cameraMode (enum text) and update the camera mode text view + // Update camera enabled and mode status view if incorrect + /* + val videoModePreferred = preferences.getBoolean( + Constants.PREFERENCES_CAMERA_VIDEO_MODE_ENABLED_KEY, + false + ) + val rawModePreferred = preferences.getBoolean( + Constants.PREFERENCES_CAMERA_RAW_MODE_ENABLED_KEY, + false + ) + val cameraModeText = + if (videoModePreferred) "Video" else if (rawModePreferred) "DNG" else "JPEG" + val cameraStatusText = + if (!cameraModeEnabledPreferred) "disabled" else "enabled, $cameraModeText mode" + if (binding.cameraStatus.text !== cameraStatusText) { + Log.d(TAG, "updateView -> camera mode view $cameraStatusText") + binding.cameraStatus.text = cameraStatusText + }*/ + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /*override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + when (requestCode) { + // Location permission request moved to `MapFragment` as it has to react to results + + PERMISSION_REQUEST_CAMERA_AND_STORAGE_PERMISSION -> { + val granted = grantResults[0] == PackageManager.PERMISSION_GRANTED + val unexpectedPermissionNumber = grantResults.size < 2 + val missingPermissions = + !(granted && (unexpectedPermissionNumber || (grantResults[1] == PackageManager.PERMISSION_GRANTED))) + + if (missingPermissions) { + // Deactivate camera service and inform user about this + deactivateCameraService() + Toast.makeText( + context, + requireContext().getString(de.cyface.camera_service.R.string.camera_service_off_missing_permissions), + Toast.LENGTH_LONG + ).show() + } else { + // Ask used which camera mode to use, video or default (shutter image) + showCameraModeDialog(this) + } + + } else -> { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + }*/ + + /** + * Displays a dialog for the user to select a camera mode (video- or picture mode). + */ + fun onCameraEnabled(fragment: SettingsFragment) { + + // Ask for camera mode + val cameraModeDialog = CameraModeDialog() + cameraModeDialog.setTargetFragment( + fragment, + de.cyface.energy_settings.Constants.DIALOG_ENERGY_SAFER_WARNING_CODE + ) + cameraModeDialog.isCancelable = false + cameraModeDialog.show(requireFragmentManager(), "CAMERA_MODE_DIALOG") + + updateCameraSettingsView(true) + } + + private fun disabledCamera() { + //binding.cameraSwitch.isChecked = false // Should be done via observe + viewModel.setCameraEnabled(false) + onCameraDisabled() + } + + private fun onCameraDisabled() { + } + + private fun updateCameraSettingsView(cameraEnabled: Boolean) { + if (!cameraEnabled) { + binding.cameraSettingsWrapper.visibility = View.GONE + return + } + binding.cameraSettingsWrapper.visibility = View.VISIBLE + + // Set manual sensor support + val characteristics = loadCameraCharacteristics() + viewModel.manualSensorSupported = Utils.isFeatureSupported( + characteristics, + CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_MANUAL_SENSOR + ) + + // Static Focus Distance + binding.staticFocusSwitcher.setOnCheckedChangeListener( + StaticFocusSwitchHandler( + requireContext(), + viewModel, + binding.staticFocusSwitcher, + binding.staticFocusDistanceSlider, + binding.staticFocus, + binding.staticFocusUnit + ) + ) + binding.staticFocusDistanceSlider.addOnChangeListener( + StaticFocusDistanceSlideHandler(viewModel, binding.staticFocus) + ) + val minFocusDistance = + characteristics!!.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) + // is null when camera permissions are missing, is 0.0 when "lens is fixed-focus" (e.g. emulators) + // It's ok when this is not set in those cases as the fragment informs about missing manual focus mode + if (minFocusDistance != null && minFocusDistance.toDouble() != 0.0) { + // Flooring to the next smaller 0.25 step as max focus distance to fix exception: + // stepSize(0.25) must be a factor of range [0.25;9.523809] on Pixel 6 + binding.staticFocusDistanceSlider.valueTo = floor(minFocusDistance * 4) / 4 + } + + // Distance based triggering + binding.distanceBasedSwitcher.setOnCheckedChangeListener( + DistanceBasedSwitchHandler( + requireContext(), + viewModel, + binding.distanceBasedSlider, + binding.distanceBased, + binding.distanceBasedUnit + ) + ) + binding.distanceBasedSlider.addOnChangeListener( + TriggerDistanceSlideHandler(viewModel, binding.distanceBased) + ) + // triggerDistanceSlider.setValueTo(minFocusDistance); + binding.distanceBasedUnit.text = TRIGGER_DISTANCE_UNIT + + // Static Exposure Time + binding.staticExposureTimeSwitcher.setOnCheckedChangeListener( + StaticExposureSwitchHandler( + viewModel, + requireContext(), + binding.staticExposureTimeSwitcher, + binding.staticExposureTime, + binding.staticExposureTimeUnit, + binding.staticExposureValueTitle, + binding.staticExposureValueSlider, + binding.staticExposureValue, + binding.staticExposureValueDescription + ) + ) + binding.staticExposureTime.setOnClickListener( + StaticExposureTimeClickHandler( + parentFragmentManager, + this + ) + ) + binding.staticExposureTimeUnit.text = EXPOSURE_TIME_UNIT + + // Static Exposure Value (Dialog) + binding.staticExposureValueSlider.addOnChangeListener( + StaticExposureValueSlideHandler( + viewModel, + binding.staticExposureValue, + binding.staticExposureValueDescription + ) + ) + binding.staticExposureValueSlider.valueFrom = + EXPOSURE_VALUES.firstKey()!!.toFloat() + binding.staticExposureValueSlider.valueTo = + EXPOSURE_VALUES.lastKey()!!.toFloat() + + // The slider is not useful for a set of predefined values like 1E9/54444, 1/8000, 1/125 + /*val exposureTimeRangeNanos = + characteristics.get(CameraCharacteristics.SENSOR_INFO_EXPOSURE_TIME_RANGE); + if (exposureTimeRangeNanos != null) { // when camera permissions are missing + // Limit maximal time to 1/100s for easy motion-blur-reduced ranges: 1/125s and less + val toValue = /*10_000_000*/ /*10_000_000L.coerceAtMost(*/ + exposureTimeRangeNanos.upper//) + val fromValue = /*1_250_000*/exposureTimeRangeNanos.lower + binding.staticExposureTimeSlider.valueTo = toValue.toFloat() + binding.staticExposureTimeSlider.valueFrom = fromValue.toFloat() + Log.d(TAG,"exposureTimeRange: $exposureTimeRangeNanos ns -> set slide range: $fromValue - $toValue") + } + */ + + // Show supported camera features (camera permissions required) + displaySupportLevelsAndUnits( + characteristics, + viewModel.manualSensorSupported, + minFocusDistance!! + ) + } + + private fun updateDistanceBasedTriggeringViewToPreference() { + // Update slider view if incorrect + val preferredTriggerDistance = viewModel.triggeringDistance.value + val roundedDistance = (preferredTriggerDistance!! * 100).roundToInt() / 100f + if (binding.distanceBasedSlider.value != roundedDistance) { + Log.d(TAG, "updateView -> triggering distance slider $roundedDistance") + binding.distanceBasedSlider.value = roundedDistance + } + if (binding.distanceBased.text.isEmpty() + || binding.distanceBased.text.toString().toFloat() != roundedDistance + ) { + Log.d(TAG, "updateView -> triggering distance text $roundedDistance") + binding.distanceBased.text = roundedDistance.toString() + } + + // Update switcher view if incorrect + val distanceBasedTriggeringPreferred = viewModel.distanceBasedTriggering.value + if (binding.distanceBasedSwitcher.isChecked != distanceBasedTriggeringPreferred) { + Log.d(TAG, "distance based triggering -> $distanceBasedTriggeringPreferred") + binding.distanceBasedSwitcher.isChecked = distanceBasedTriggeringPreferred!! + } + + // Update visibility slider + distance field + val expectedTriggerDistanceVisibility = + if (distanceBasedTriggeringPreferred) View.VISIBLE else View.INVISIBLE + Log.d( + TAG, + "view status -> distance triggering fields are " + binding.distanceBasedSlider.visibility + + "expected is: " + + if (expectedTriggerDistanceVisibility == View.VISIBLE) "visible" else "invisible" + ) + if (binding.distanceBasedSlider.visibility != expectedTriggerDistanceVisibility || binding.distanceBased.visibility != expectedTriggerDistanceVisibility || binding.distanceBasedUnit.visibility != expectedTriggerDistanceVisibility) { + Log.d( + TAG, "updateView -> distance triggering fields to " + + if (expectedTriggerDistanceVisibility == View.VISIBLE) "visible" else "invisible" + ) + binding.distanceBasedSlider.visibility = expectedTriggerDistanceVisibility + binding.distanceBased.visibility = expectedTriggerDistanceVisibility + binding.distanceBasedUnit.visibility = expectedTriggerDistanceVisibility + } + } + + + /** + * Returns the features supported by the camera hardware. + * + * @return The hardware feature support + */ + private fun loadCameraCharacteristics(): CameraCharacteristics? { + val cameraManager = + requireContext().getSystemService(Context.CAMERA_SERVICE) as CameraManager + return try { + Validate.notNull(cameraManager) + val cameraId = Utils.getMainRearCameraId(cameraManager, null) + cameraManager.getCameraCharacteristics(cameraId) + } catch (e: CameraAccessException) { + throw IllegalStateException(e) + } + } + + private fun updateManualSensorViewToPreferences() { + // Update focus distance slider view if incorrect + val preferredFocusDistance = viewModel.staticFocusDistance.value!! + val roundedDistance = (preferredFocusDistance * 100).roundToInt() / 100f + if (binding.staticFocusDistanceSlider.value != roundedDistance) { + Log.d(TAG, "updateView -> focus distance slider $roundedDistance") + binding.staticFocusDistanceSlider.value = roundedDistance + } + if (binding.staticFocus.text.isEmpty() + || binding.staticFocus.text.toString().toFloat() != roundedDistance + ) { + Log.d(TAG, "updateView -> focus distance text $roundedDistance") + binding.staticFocus.text = roundedDistance.toString() + } + + // Update exposure time slider view if incorrect + val preferredExposureTimeNanos = viewModel.staticExposureTime.value!! + /* + * if (staticExposureTimeSlider.getValue() != preferredExposureTimeNanos) { + * Log.d(TAG, "updateView -> exposure time slider " + preferredExposureTimeNanos); + * staticExposureTimeSlider.setValue(preferredExposureTimeNanos); + * } + */if (binding.staticExposureTime.text.isEmpty() + || binding.staticExposureTime.text.toString() != Utils.getExposureTimeFraction( + preferredExposureTimeNanos + ) + ) { + Log.d(TAG, "updateView -> exposure time text $preferredExposureTimeNanos") + binding.staticExposureTime.text = Utils.getExposureTimeFraction( + preferredExposureTimeNanos + ) + } + + // Update exposure value slider view if incorrect + val preferredExposureValue = viewModel.staticExposureValue.value!! + if (binding.staticExposureValueSlider.value != preferredExposureValue.toFloat()) { + Log.d(TAG, "updateView -> exposure value slider $preferredExposureValue") + binding.staticExposureValueSlider.value = preferredExposureValue.toFloat() + } + if (binding.staticExposureValue.text.isEmpty() || binding.staticExposureValue.text.toString() + .toInt() != preferredExposureValue + ) { + Log.d(TAG, "updateView -> exposure value text $preferredExposureValue") + binding.staticExposureValue.text = preferredExposureValue.toString() + } + // Update exposure value description view if incorrect + val expectedExposureValueDescription = EXPOSURE_VALUES[preferredExposureValue] + if (binding.staticExposureValueDescription.text != expectedExposureValueDescription) { + Log.d(TAG, "exposure value description -> $expectedExposureValueDescription") + binding.staticExposureValueDescription.text = expectedExposureValueDescription + } + + // Update focus distance switcher view if incorrect + val focusDistancePreferred = viewModel.staticFocus.value!! + if (binding.staticFocusSwitcher.isChecked != focusDistancePreferred) { + Log.d(TAG, "updateView focus distance switcher -> $focusDistancePreferred") + binding.staticFocusSwitcher.isChecked = focusDistancePreferred + } + + // Update exposure time switcher view if incorrect + val exposureTimePreferred = viewModel.staticExposure.value!! + if (binding.staticExposureTimeSwitcher.isChecked != exposureTimePreferred) { + Log.d(TAG, "updateView exposure time switcher -> $exposureTimePreferred") + binding.staticExposureTimeSwitcher.isChecked = exposureTimePreferred + } + + // Update visibility of focus distance slider + distance field + val expectedFocusDistanceVisibility = + if (focusDistancePreferred) View.VISIBLE else View.INVISIBLE + if (binding.staticFocusDistanceSlider.visibility != expectedFocusDistanceVisibility || + binding.staticFocus.visibility != expectedFocusDistanceVisibility || + binding.staticFocusUnit.visibility != expectedFocusDistanceVisibility + ) { + Log.d( + TAG, "updateView -> focus distance fields to " + + if (expectedFocusDistanceVisibility == View.VISIBLE) "visible" else "invisible" + ) + binding.staticFocusDistanceSlider.visibility = expectedFocusDistanceVisibility + binding.staticFocus.visibility = expectedFocusDistanceVisibility + binding.staticFocusUnit.visibility = expectedFocusDistanceVisibility + } + + // Update visibility of exposure time and value slider + time and value fields + val expectedExposureTimeVisibility = + if (exposureTimePreferred) View.VISIBLE else View.INVISIBLE + if ( /* + * binding.staticExposureTimeSlider.getVisibility() != expectedExposureTimeVisibility + * || + */binding.staticExposureTime.visibility != expectedExposureTimeVisibility || binding.staticExposureTimeUnit.visibility != expectedExposureTimeVisibility || binding.staticExposureValueTitle.visibility != expectedExposureTimeVisibility || binding.staticExposureValueSlider.visibility != expectedExposureTimeVisibility || binding.staticExposureValue.visibility != expectedExposureTimeVisibility || binding.staticExposureValueDescription.visibility != expectedExposureTimeVisibility) { + Log.d( + TAG, "updateView -> exposure time fields to " + + if (expectedExposureTimeVisibility == View.VISIBLE) "visible" else "invisible" + ) + // binding.staticExposureTimeSlider.setVisibility(expectedExposureTimeVisibility); + binding.staticExposureTime.visibility = expectedExposureTimeVisibility + binding.staticExposureTimeUnit.visibility = expectedExposureTimeVisibility + binding.staticExposureValueTitle.visibility = expectedExposureTimeVisibility + binding.staticExposureValueSlider.visibility = expectedExposureTimeVisibility + binding.staticExposureValue.visibility = expectedExposureTimeVisibility + binding.staticExposureValueDescription.visibility = expectedExposureTimeVisibility + } + } + + /** + * Displays the supported camera2 features in the view. + * + * @param characteristics the features to check + * @param isManualSensorSupported `True` if the 'manual sensor' feature is supported + * @param minFocusDistance the minimum focus distance supported + */ + private fun displaySupportLevelsAndUnits( + characteristics: CameraCharacteristics, + isManualSensorSupported: Boolean, minFocusDistance: Float + ) { + + // Display camera2 API support level + val camera2Level = getCamera2SupportLevel(characteristics) + binding.hardwareSupport.text = camera2Level + + // Display 'manual sensor' support + binding.manualSensorSupport.text = + if (isManualSensorSupported) "supported" else "no supported" + + // Display whether the focus distance setting is calibrated (i.e. has a unit) + val calibrationLevel = getFocusDistanceCalibration(characteristics) + val unitDetails = + if (calibrationLevel == FOCUS_DISTANCE_NOT_CALIBRATED) "uncalibrated" else "dioptre" + val unit = + if (calibrationLevel == FOCUS_DISTANCE_NOT_CALIBRATED) "" else " [dioptre]" + binding.focusDistance.text = calibrationLevel + binding.staticFocusUnit.text = unitDetails + + // Display focus distance ranges + val hyperFocalDistance = + characteristics.get(CameraCharacteristics.LENS_INFO_HYPERFOCAL_DISTANCE) + val hyperFocalDistanceText = hyperFocalDistance.toString() + unit + val shortestFocalDistanceText = minFocusDistance.toString() + unit + binding.hyperFocalDistance.text = hyperFocalDistanceText + binding.minimumFocusDistance.text = shortestFocalDistanceText + } + + /** + * Returns the hardware calibration of the focus distance feature as simple `String`. + * + * If "uncalibrated" the focus distance does not have a unit, else it's in dioptre [1/m], more details: + * https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics#LENS_INFO_FOCUS_DISTANCE_CALIBRATION + * + * @param characteristics the hardware feature set to check + * @return the calibration level as `String` + */ + private fun getFocusDistanceCalibration(characteristics: CameraCharacteristics): String { + val focusDistanceCalibration = characteristics + .get(CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION) ?: return "N/A" + return when (focusDistanceCalibration) { + CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED -> "calibrated" + CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE -> "approximate" + CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_UNCALIBRATED -> FOCUS_DISTANCE_NOT_CALIBRATED + else -> "unknown: $focusDistanceCalibration" + } + } + + /** + * Returns the support level for the 'camera2' API, see http://stackoverflow.com/a/31240881/5815054. + * + * @param characteristics the camera hardware features to check + * @return the support level as simple `String` + */ + private fun getCamera2SupportLevel(characteristics: CameraCharacteristics): String { + val supportedHardwareLevel = characteristics + .get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) + var supportLevel = "unknown" + if (supportedHardwareLevel != null) { + when (supportedHardwareLevel) { + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY -> supportLevel = "legacy" + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED -> supportLevel = "limited" + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL -> supportLevel = "full" + } + } + return supportLevel + } + + + /** + * Called when an exposure time was selected in the [ExposureTimeDialog]. + * + * @param data an intent which may contain result data + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE) { + val exposureTimeNanos = data!!.getLongExtra( + Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, + -1L + ) + Validate.isTrue(exposureTimeNanos != -1L) + val fraction = Utils.getExposureTimeFraction(exposureTimeNanos) + Log.d( + TAG, + "Update view to exposure time -> $exposureTimeNanos ns - fraction: $fraction s" + ) + binding.staticExposureTime.text = fraction + } + } + + companion object { + /** + * The tag used to identify logging from this class. + */ + const val TAG = de.cyface.app.digural.utils.Constants.PACKAGE + ".s" + + /** + * The identifier for the [ExposureTimeDialog] request. + */ + const val DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE = 202002170 + + /** + * The {@code String} which represents the hardware camera focus distance calibration level. + */ + private const val FOCUS_DISTANCE_NOT_CALIBRATED = "uncalibrated" + + /** + * The unit of the [.staticExposureTimePreference] value shown in the **UI**. + */ + private const val EXPOSURE_TIME_UNIT = "s (click on time to change)" + + /** + * The unit of the [.triggerDistancePreference] value shown in the **UI**. + */ + private const val TRIGGER_DISTANCE_UNIT = "m" + + /** + * Exposure value from tabulates values (for iso 100) for outdoor environment light settings + * see https://en.wikipedia.org/wiki/Exposure_value#Tabulated_exposure_values + */ + val EXPOSURE_VALUES: TreeMap = object : TreeMap() { + init { + put(10, "twilight") + put(11, "twilight") + put(12, "deep shade") + put(13, "cloudy, no shadows") + put(14, "partly cloudy, soft shadows") + put(15, "full sunlight") + put(16, "sunny, snowy/sandy") + } + } + } +} + + +// final SwitchCompat connectToExternalSpeedSensorToggle = (SwitchCompat)view.getMenu() +// .findItem(R.id.drawer_setting_speed_sensor).getActionView(); + +/* +final boolean bluetoothIsConfigured = preferences.getString(BLUETOOTHLE_DEVICE_MAC_KEY, null) != null +&& preferences.getFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, 0.0F) > 0.0F; +connectToExternalSpeedSensorToggle.setChecked(bluetoothIsConfigured); + +// connectToExternalSpeedSensorToggle.setOnClickListener(new ConnectToExternalSpeedSensorToggleListener()); + + +/* + * A listener which is called when the external bluetooth sensor toggle in the {@link NavDrawer} is clicked. + * / + * private class ConnectToExternalSpeedSensorToggleListener implements CompoundButton.OnCheckedChangeListener { + * + * @Override + * public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + * final CompoundButton compoundButton = (CompoundButton)view; + * final Context applicationContext = view.getContext().getApplicationContext(); + * if (compoundButton.isChecked()) { + * final BluetoothLeSetup bluetoothLeSetup = new BluetoothLeSetup(new BluetoothLeSetupListener() { + * + * @Override + * public void onDeviceSelected(final BluetoothDevice device, final double wheelCircumference) { + * final SharedPreferences.Editor editor = preferences.edit(); + * editor.putString(BLUETOOTHLE_DEVICE_MAC_KEY, device.getAddress()); + * editor.putFloat(BLUETOOTHLE_WHEEL_CIRCUMFERENCE, + * Double.valueOf(wheelCircumference).floatValue()); + * editor.apply(); + * } + * + * @Override + * public void onSetupProcessFailed(final Reason reason) { + * compoundButton.setChecked(false); + * if (reason.equals(Reason.NOT_SUPPORTED)) { + * Toast.makeText(applicationContext, R.string.ble_not_supported, Toast.LENGTH_SHORT) + * .show(); + * } else { + * Log.e(TAG, "Setup process of bluetooth failed: " + reason); + * Toast.makeText(applicationContext, R.string.bluetooth_setup_failed, Toast.LENGTH_SHORT) + * .show(); + * } + * } + * }); + * bluetoothLeSetup.setup(mainActivity); + * } else { + * final SharedPreferences.Editor editor = preferences.edit(); + * editor.remove(BLUETOOTHLE_DEVICE_MAC_KEY); + * editor.remove(BLUETOOTHLE_WHEEL_CIRCUMFERENCE); + * editor.apply(); + * } + * } + * } + */ \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModel.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModel.kt new file mode 100644 index 00000000..266d2155 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModel.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import de.cyface.camera_service.CameraPreferences +import de.cyface.utils.AppPreferences + +/** + * This is the [ViewModel] for the [SettingsFragment]. + * + * It holds the UI data/state for that UI element in a lifecycle-aware way, surviving configuration changes. + * + * It acts as a communicator between the data layer's `Repository` and the UI layer's UI elements. + * + * *Attention*: + * - Don't keep references to a `Context` that has a shorter lifecycle than the [ViewModel]. + * https://developer.android.com/codelabs/android-room-with-a-view-kotlin#9 + * - [ViewModel]s don't survive when the app's process is killed in the background. + * UI data which needs to survive this, use "Saved State module for ViewModels": + * https://developer.android.com/topic/libraries/architecture/viewmodel-savedstate + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.4.0 + * @param appPreferences Persistence storage of the app preferences. + * @param cameraPreferences Persistence storage of the camera preferences. + */ +class SettingsViewModel( + private val appPreferences: AppPreferences, + private val cameraPreferences: CameraPreferences +) : ViewModel() { + + private val _centerMap = MutableLiveData() + private val _upload = MutableLiveData() + private val _sensorFrequency = MutableLiveData() + + /** camera settings **/ + private val _cameraEnabled = MutableLiveData() + private val _videoMode = MutableLiveData() + private val _rawMode = MutableLiveData() + private val _distanceBasedTriggering = MutableLiveData() + private val _triggeringDistance = MutableLiveData() + private val _staticFocus = MutableLiveData() + private val _staticFocusDistance = MutableLiveData() + private val _staticExposure = MutableLiveData() + private val _staticExposureTime = MutableLiveData() + private val _staticExposureValue = MutableLiveData() + + /** + * {@code True} if the camera allows to control the sensors (focus, exposure, etc.) manually. + */ + var manualSensorSupported = false + + init { + _centerMap.value = appPreferences.getCenterMap() + _upload.value = appPreferences.getUpload() + _sensorFrequency.value = appPreferences.getSensorFrequency() + /** camera settings **/ + _cameraEnabled.value = cameraPreferences.getCameraEnabled() + _videoMode.value = cameraPreferences.getVideoMode() + _rawMode.value = cameraPreferences.getRawMode() + _distanceBasedTriggering.value = cameraPreferences.getDistanceBasedTriggering() + _triggeringDistance.value = cameraPreferences.getTriggeringDistance() + _staticFocus.value = cameraPreferences.getStaticFocus() + _staticFocusDistance.value = cameraPreferences.getStaticFocusDistance() + _staticExposure.value = cameraPreferences.getStaticExposure() + _staticExposureTime.value = cameraPreferences.getStaticExposureTime() + _staticExposureValue.value = cameraPreferences.getStaticExposureValue() + } + + val centerMap: LiveData = _centerMap + val upload: LiveData = _upload + val sensorFrequency: LiveData = _sensorFrequency + + /** camera settings **/ + val cameraEnabled: LiveData = _cameraEnabled + val videoMode: LiveData = _videoMode + val rawMode: LiveData = _rawMode + val distanceBasedTriggering: LiveData = _distanceBasedTriggering + val triggeringDistance: LiveData = _triggeringDistance + val staticFocus: LiveData = _staticFocus + val staticFocusDistance: LiveData = _staticFocusDistance + val staticExposure: LiveData = _staticExposure + val staticExposureTime: LiveData = _staticExposureTime + val staticExposureValue: LiveData = _staticExposureValue + + fun setCenterMap(centerMap: Boolean) { + appPreferences.saveCenterMap(centerMap) + _centerMap.postValue(centerMap) + } + + fun setUpload(upload: Boolean) { + appPreferences.saveUpload(upload) + _upload.postValue(upload) + } + + fun setSensorFrequency(sensorFrequency: Int) { + appPreferences.saveSensorFrequency(sensorFrequency) + _sensorFrequency.postValue(sensorFrequency) + } + + /** camera settings **/ + fun setCameraEnabled(cameraEnabled: Boolean) { + cameraPreferences.saveCameraEnabled(cameraEnabled) + _cameraEnabled.postValue(cameraEnabled) + } + + fun setVideoMode(videoMode: Boolean) { + cameraPreferences.saveVideoMode(videoMode) + _videoMode.postValue(videoMode) + } + + fun setRawMode(rawMode: Boolean) { + cameraPreferences.saveRawMode(rawMode) + _rawMode.postValue(rawMode) + } + + fun setDistanceBasedTriggering(distanceBasedTriggering: Boolean) { + cameraPreferences.saveDistanceBasedTriggering(distanceBasedTriggering) + _distanceBasedTriggering.postValue(distanceBasedTriggering) + } + + fun setTriggeringDistance(triggeringDistance: Float) { + cameraPreferences.saveTriggeringDistance(triggeringDistance) + _triggeringDistance.postValue(triggeringDistance) + } + + fun setStaticFocus(staticFocus: Boolean) { + cameraPreferences.saveStaticFocus(staticFocus) + _staticFocus.postValue(staticFocus) + } + + fun setStaticFocusDistance(staticFocusDistance: Float) { + cameraPreferences.saveStaticFocusDistance(staticFocusDistance) + _staticFocusDistance.postValue(staticFocusDistance) + } + + fun setStaticExposure(staticExposure: Boolean) { + cameraPreferences.saveStaticExposure(staticExposure) + _staticExposure.postValue(staticExposure) + } + + fun setStaticExposureTime(staticExposureTime: Long) { + cameraPreferences.saveStaticExposureTime(staticExposureTime) + _staticExposureTime.postValue(staticExposureTime) + } + + fun setStaticExposureValue(staticExposureValue: Int) { + cameraPreferences.saveStaticExposureValue(staticExposureValue) + _staticExposureValue.postValue(staticExposureValue) + } +} + diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModelFactory.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModelFactory.kt new file mode 100644 index 00000000..51be4597 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/SettingsViewModelFactory.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import de.cyface.camera_service.CameraPreferences +import de.cyface.utils.AppPreferences + +/** + * Factory which creates the [ViewModel] with the required dependencies. + * + * Survives configuration changes and returns the right instance after Activity recreation. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.4.0 + * @param appPreferences Persistence storage of the app preferences. + * @param cameraPreferences Persistence storage of the camera preferences. + */ +class SettingsViewModelFactory( + private val appPreferences: AppPreferences, + private val cameraPreferences: CameraPreferences +) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return SettingsViewModel(appPreferences, cameraPreferences) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureSwitchHandler.kt new file mode 100644 index 00000000..92dc3af5 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureSwitchHandler.kt @@ -0,0 +1,81 @@ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.CompoundButton +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.SwitchCompat +import com.google.android.material.slider.Slider +import de.cyface.app.digural.R +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG + +/** + * Handles UI changes of the 'switcher' used to en-/disable 'static exposure time' feature. + */ +class StaticExposureSwitchHandler( + private val viewModel: SettingsViewModel, + private val context: Context, + private val staticExposureTimeSwitcher: SwitchCompat, + private val staticExposureTime: TextView, + private val staticExposureTimeUnit: TextView, + private val staticExposureValueTitle: TextView, + private val staticExposureValueSlider: Slider, + private val staticExposureValue: TextView, + private val staticExposureValueDescription: TextView +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + if (!viewModel.manualSensorSupported && isChecked) { + Toast.makeText( + context, + "This device does not support manual exposure control", + Toast.LENGTH_LONG + ).show() + staticExposureTimeSwitcher.isChecked = false + return + } + + if (viewModel.staticExposure.value != isChecked) { + Log.d( + TAG, + "Update preference to exposure -> " + if (isChecked) "Tv-/S-Mode" else "auto" + ) + viewModel.setStaticExposure(isChecked) + if (isChecked) { + Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG) + .show() + } + + // Update visibility of slider - FIXME: do via observe + val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE + // final boolean sliderVisibilityOutOfSync = staticExposureTimeSlider.getVisibility() != expectedVisibility; + val preferenceVisibilityOutOfSync = staticExposureTime.visibility != expectedVisibility + val unitVisibilityOutOfSync = staticExposureTimeUnit.visibility != expectedVisibility + val evTitleVisibilityOutOfSync = + staticExposureValueTitle.visibility != expectedVisibility + val evSliderVisibilityOutOfSync = + staticExposureValueSlider.visibility != expectedVisibility + val evPreferenceVisibilityOutOfSync = + staticExposureValue.visibility != expectedVisibility + val evDescriptionVisibilityOutOfSync = + staticExposureValueDescription.visibility != expectedVisibility + if ( /* sliderVisibilityOutOfSync || */preferenceVisibilityOutOfSync || unitVisibilityOutOfSync + || evTitleVisibilityOutOfSync || evSliderVisibilityOutOfSync || evPreferenceVisibilityOutOfSync + || evDescriptionVisibilityOutOfSync + ) { + Log.d( + TAG, + "updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" + ) + // staticExposureTimeSlider.setVisibility(expectedVisibility); + staticExposureTime.visibility = expectedVisibility + staticExposureTimeUnit.visibility = expectedVisibility + staticExposureValueTitle.visibility = expectedVisibility + staticExposureValueSlider.visibility = expectedVisibility + staticExposureValue.visibility = expectedVisibility + staticExposureValueDescription.visibility = expectedVisibility + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeClickHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeClickHandler.kt new file mode 100644 index 00000000..2c6bbeea --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeClickHandler.kt @@ -0,0 +1,29 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.view.View +import androidx.fragment.app.FragmentManager +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG +import de.cyface.app.digural.dialog.ExposureTimeDialog +import de.cyface.utils.Validate + +/** + * Handles UI clicks on the exposure time used to adjust the 'exposure time' setting. + */ +class StaticExposureTimeClickHandler( + private val fragmentManager: FragmentManager?, + private val settingsFragment: SettingsFragment +) : View.OnClickListener { + override fun onClick(v: View) { + Log.d(TAG, "StaticExposureTimeClickHandler triggered, showing ExposureTimeDialog") + Validate.notNull(fragmentManager) + val dialog = ExposureTimeDialog() + dialog.setTargetFragment( + settingsFragment, + DIALOG_EXPOSURE_TIME_SELECTION_REQUEST_CODE + ) + dialog.isCancelable = true + dialog.show(fragmentManager!!, "EXPOSURE_TIME_DIALOG") + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeSlideHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeSlideHandler.kt new file mode 100644 index 00000000..b0582b30 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureTimeSlideHandler.kt @@ -0,0 +1,27 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.widget.TextView +import com.google.android.material.slider.Slider +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG +import de.cyface.camera_service.Utils + +/* + * Handles UI changes of the 'slider' used to adjust the 'exposure time' setting. + */ +class StaticExposureTimeSlideHandler( + private val viewModel: SettingsViewModel, + private val staticExposureValue: TextView +) : + Slider.OnChangeListener { + override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { + if (viewModel.staticExposureTime.value!!.toFloat() != newValue) { + Log.d(TAG, "Update preference to exposure time -> $newValue ns") + val value = newValue.toLong() + viewModel.setStaticExposureTime(value) + + // FIXME: do via observe + staticExposureValue.text = Utils.getExposureTimeFraction(value) + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureValueSlideHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureValueSlideHandler.kt new file mode 100644 index 00000000..6ecbcd6a --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticExposureValueSlideHandler.kt @@ -0,0 +1,29 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.widget.TextView +import com.google.android.material.slider.Slider +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.EXPOSURE_VALUES +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG + +/** + * Handles UI changes of the 'slider' used to adjust the 'exposure value' setting. + */ +class StaticExposureValueSlideHandler( + private val viewModel: SettingsViewModel, + private val staticExposureValue: TextView, + private val staticExposureValueDescription: TextView +) : Slider.OnChangeListener { + override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { + if (viewModel.staticExposureValue.value!!.toFloat() != newValue) { + Log.d(TAG, "Update preference to exposure value -> $newValue") + val value = newValue.toInt() + viewModel.setStaticExposureValue(value) + + // FIXME: do via observe + val description = EXPOSURE_VALUES[value] + staticExposureValue.text = value.toString() + staticExposureValueDescription.text = description + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusDistanceSlideHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusDistanceSlideHandler.kt new file mode 100644 index 00000000..b2eb5628 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusDistanceSlideHandler.kt @@ -0,0 +1,30 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.widget.TextView +import com.google.android.material.slider.Slider +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG +import kotlin.math.roundToInt + +/** + * Handles UI changes of the 'slider' used to adjust the 'focus distance' setting. + */ +class StaticFocusDistanceSlideHandler( + private val viewModel: SettingsViewModel, + private val staticFocus: TextView +) : Slider.OnChangeListener { + override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { + val roundedDistance = (newValue * 100).roundToInt() / 100f + if (viewModel.staticFocusDistance.value != roundedDistance) { + Log.d(TAG, "Update preference to focus distance -> $roundedDistance") + viewModel.setStaticFocusDistance(roundedDistance) + + // FIXME: do via observe + val text = StringBuilder(roundedDistance.toString()) + while (text.length < 4) { + text.append("0") + } + staticFocus.text = text + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusSwitchHandler.kt new file mode 100644 index 00000000..b8f317c5 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/StaticFocusSwitchHandler.kt @@ -0,0 +1,60 @@ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.util.Log +import android.view.View +import android.widget.CompoundButton +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.widget.SwitchCompat +import com.google.android.material.slider.Slider +import de.cyface.app.digural.R +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG + +/** + * Handles UI changes of the 'switcher' used to en-/disable 'static focus' feature. + */ +class StaticFocusSwitchHandler( + private val context: Context, + private val viewModel: SettingsViewModel, + private val staticFocusSwitcher: SwitchCompat, + private val staticFocusDistanceSlider: Slider, + private val staticFocus: TextView, + private val staticFocusUnit: TextView +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + if (!viewModel.manualSensorSupported && isChecked) { + Toast.makeText( + context, + "This device does not support manual focus control", + Toast.LENGTH_LONG + ).show() + staticFocusSwitcher.isChecked = false + return + } + + if (viewModel.staticFocus.value != isChecked) { + Log.d(TAG, "Update preference to focus -> " + if (isChecked) "manual" else "auto") + viewModel.setStaticFocus(isChecked) + if (isChecked) { + Toast.makeText(context, R.string.experimental_feature_warning, Toast.LENGTH_LONG) + .show() + } + + // Update visibility of slider - FIXME: do via observe + val expectedVisibility = if (isChecked) View.VISIBLE else View.INVISIBLE + val sliderVisibilityOutOfSync = staticFocusDistanceSlider.visibility != expectedVisibility + val preferenceVisibilityOutOfSync = staticFocus.visibility != expectedVisibility + val unitVisibilityOutOfSync = staticFocusUnit.visibility != expectedVisibility + if (sliderVisibilityOutOfSync || preferenceVisibilityOutOfSync || unitVisibilityOutOfSync) { + Log.d( + TAG, + "updateView -> " + if (expectedVisibility == View.VISIBLE) "visible" else "invisible" + ) + staticFocusDistanceSlider.visibility = expectedVisibility + staticFocus.visibility = expectedVisibility + staticFocusUnit.visibility = expectedVisibility + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/TriggerDistanceSlideHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/TriggerDistanceSlideHandler.kt new file mode 100644 index 00000000..819f88b5 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/TriggerDistanceSlideHandler.kt @@ -0,0 +1,30 @@ +package de.cyface.app.digural.capturing.settings + +import android.util.Log +import android.widget.TextView +import com.google.android.material.slider.Slider +import de.cyface.app.digural.capturing.settings.SettingsFragment.Companion.TAG +import kotlin.math.roundToInt + +/** + * Handles UI changes of the 'slider' used to adjust the 'triggering distance' setting. + */ +class TriggerDistanceSlideHandler( + private val viewModel: SettingsViewModel, + private val distanceBased: TextView +) : Slider.OnChangeListener { + override fun onValueChange(slider: Slider, newValue: Float, fromUser: Boolean) { + val roundedDistance = (newValue * 100).roundToInt() / 100f + if (viewModel.triggeringDistance.value != roundedDistance) { + Log.d(TAG, "Update preference to triggering distance -> $roundedDistance") + viewModel.setTriggeringDistance(roundedDistance) + + // FIXME: do via observe + val text = StringBuilder(roundedDistance.toString()) + while (text.length < 4) { + text.append("0") + } + distanceBased.text = text + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/UploadSwitchHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/UploadSwitchHandler.kt new file mode 100644 index 00000000..8a6b45e9 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/capturing/settings/UploadSwitchHandler.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.capturing.settings + +import android.content.Context +import android.widget.CompoundButton +import android.widget.Toast +import de.cyface.app.utils.R +import de.cyface.datacapturing.CyfaceDataCapturingService + +/** + * Handles when the user toggles the upload switch. + * + * @author Armin Schnabel + * @version 1.0.0 + * @since 3.2.0 + */ +class UploadSwitchHandler( + private val viewModel: SettingsViewModel, + private val context: Context?, + private val capturingService: CyfaceDataCapturingService +) : CompoundButton.OnCheckedChangeListener { + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + if (viewModel.upload.value != isChecked) { + // Also update WifiSurveyor's synchronizationEnabled + capturingService.wiFiSurveyor.isSyncEnabled = isChecked + viewModel.setUpload(isChecked) + + // Show warning to user (storage gets filled) + if (!isChecked) { + Toast.makeText( + context, + R.string.sync_disabled_toast, + Toast.LENGTH_LONG + ).show() + } + } + } +} \ No newline at end of file diff --git a/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ExposureTimeDialog.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ExposureTimeDialog.kt similarity index 89% rename from ui/cyface/src/main/kotlin/de/cyface/app/dialog/ExposureTimeDialog.kt rename to ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ExposureTimeDialog.kt index fdf9a405..b8983409 100644 --- a/ui/cyface/src/main/kotlin/de/cyface/app/dialog/ExposureTimeDialog.kt +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ExposureTimeDialog.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017 Cyface GmbH + * Copyright 2017-2023 Cyface GmbH * * This file is part of the Cyface App for Android. * @@ -16,18 +16,18 @@ * You should have received a copy of the GNU General Public License * along with the Cyface App for Android. If not, see . */ -package de.cyface.app.dialog +package de.cyface.app.digural.dialog import android.app.AlertDialog import android.app.Dialog import android.content.DialogInterface import android.content.Intent import android.os.Bundle -import android.preference.PreferenceManager import android.util.Log import androidx.fragment.app.DialogFragment -import de.cyface.app.R -import de.cyface.app.capturing.SettingsFragment +import de.cyface.app.digural.R +import de.cyface.app.digural.capturing.settings.SettingsFragment +import de.cyface.camera_service.CameraPreferences import de.cyface.camera_service.Constants import de.cyface.utils.Validate import kotlin.math.round @@ -47,8 +47,6 @@ class ExposureTimeDialog : DialogFragment() { ) { _: DialogInterface?, which: Int -> val fragmentActivity = activity Validate.notNull(fragmentActivity) - val editor = PreferenceManager - .getDefaultSharedPreferences(fragmentActivity!!.applicationContext).edit() // Pixel 3a reference device: EV100 10, f/1.8, 1/125s (2ms) => iso 40 (55 used, minimum) val exposureTimeNanos: Long = when (which) { 0 -> round(1000000000.0 / 125).toLong() @@ -62,8 +60,8 @@ class ExposureTimeDialog : DialogFragment() { else -> throw IllegalArgumentException("Unknown exposure time selected: $which") } Log.d(Constants.TAG, "Update preference to exposure time -> $exposureTimeNanos ns") - editor.putLong(Constants.PREFERENCES_CAMERA_STATIC_EXPOSURE_TIME_KEY, exposureTimeNanos) - .apply() + val preferences = CameraPreferences(fragmentActivity!!.applicationContext) + preferences.saveStaticExposureTime(exposureTimeNanos) val requestCode = targetRequestCode val resultCode: Int val intent = Intent() diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ModalityDialog.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ModalityDialog.kt new file mode 100644 index 00000000..d5599e40 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/dialog/ModalityDialog.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.dialog + +import android.app.AlertDialog +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import de.cyface.app.digural.CapturingFragment +import de.cyface.app.digural.R +import de.cyface.persistence.model.Modality +import de.cyface.synchronization.BundlesExtrasCodes +import de.cyface.utils.AppPreferences +import de.cyface.utils.AppPreferences.Companion.PREFERENCES_MODALITY_KEY +import de.cyface.utils.Validate + +/** + * After the user installed the app for the first time, after accepting the terms, this dialog is shown + * to force the user to select his [Modality] type before being able to use the app. This is necessary + * as it is not possible to have NO tab (here: `Modality` type) selected visually so we make sure the correct + * one is selected by default. Else, the user might forget (oversee) to select a `Modality` at all. + * + * Make sure the order (0, 1, 2 from left(start) to right(end)) in the TabLayout is consistent in here. + * + * @author Armin Schnabel + * @version 2.1.1 + * @since 1.0.0 + */ +class ModalityDialog : DialogFragment() { + /** + * The id if the `Measurement` if this dialog is called for a specific Measurement. + */ + private var measurementId: Long? = null + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = AlertDialog.Builder(activity) + builder.setTitle(R.string.dialog_modality_title).setItems( + R.array.dialog_modality + ) { _: DialogInterface?, which: Int -> + val fragmentActivity = activity + Validate.notNull(fragmentActivity) + val preferences = AppPreferences(fragmentActivity!!.applicationContext) + val modality: Modality = when (which) { + 0 -> Modality.valueOf(Modality.CAR.name) + 1 -> Modality.valueOf(Modality.BICYCLE.name) + 2 -> Modality.valueOf(Modality.WALKING.name) + 3 -> Modality.valueOf(Modality.BUS.name) + 4 -> Modality.valueOf(Modality.TRAIN.name) + else -> throw IllegalArgumentException("Unknown modality selected: $which") + } + preferences.saveModality(modality.databaseIdentifier) + val requestCode = targetRequestCode + val resultCode: Int + val intent = Intent() + when (requestCode) { + CapturingFragment.DIALOG_INITIAL_MODALITY_SELECTION_REQUEST_CODE -> resultCode = + DIALOG_INITIAL_MODALITY_SELECTION_RESULT_CODE + + DIALOG_ADD_EVENT_MODALITY_SELECTION_REQUEST_CODE -> { + resultCode = DIALOG_ADD_EVENT_MODALITY_SELECTION_RESULT_CODE + intent.putExtra(PREFERENCES_MODALITY_KEY, modality.databaseIdentifier) + Validate.notNull(measurementId) + intent.putExtra(BundlesExtrasCodes.MEASUREMENT_ID, measurementId) + } + + else -> throw IllegalArgumentException("Unknown request code: $requestCode") + } + val targetFragment = targetFragment + Validate.notNull(targetFragment) + targetFragment!!.onActivityResult(requestCode, resultCode, intent) + } + return builder.create() + } + + fun setMeasurementId(measurementId: Long) { + this.measurementId = measurementId + } + + companion object { + private const val DIALOG_INITIAL_MODALITY_SELECTION_RESULT_CODE = 201909192 + + /** + * The identifier for the [ModalityDialog] request which asks the user to select a [Modality] when he + * adds a new `EventType.MODALITY_TYPE_CHANGE` via UI. + */ + private const val DIALOG_ADD_EVENT_MODALITY_SELECTION_REQUEST_CODE = 201909193 + private const val DIALOG_ADD_EVENT_MODALITY_SELECTION_RESULT_CODE = 201909194 + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/CameraEventHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/CameraEventHandler.kt new file mode 100644 index 00000000..ed7ce617 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/CameraEventHandler.kt @@ -0,0 +1,391 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.RingtoneManager +import android.os.Build +import android.os.Parcel +import android.os.Parcelable.Creator +import android.util.Log +import androidx.core.app.NotificationCompat +import de.cyface.app.digural.MainActivity +import de.cyface.app.digural.R +import de.cyface.app.digural.utils.Constants.TAG +import de.cyface.app.utils.SharedConstants.CAMERA_ACCESS_LOST_NOTIFICATION_ID +import de.cyface.app.utils.SharedConstants.NOTIFICATION_CHANNEL_ID_RUNNING +import de.cyface.app.utils.SharedConstants.NOTIFICATION_CHANNEL_ID_WARNING +import de.cyface.app.utils.SharedConstants.PICTURE_CAPTURING_DECREASED_NOTIFICATION_ID +import de.cyface.app.utils.SharedConstants.SPACE_WARNING_NOTIFICATION_ID +import de.cyface.camera_service.BackgroundService +import de.cyface.camera_service.EventHandlingStrategy +import de.cyface.utils.Validate + +/** + * A [EventHandlingStrategy] to respond to specified events triggered by the + * [BackgroundService]. + * + * @author Armin Schnabel + * @version 1.2.2 + * @since 1.0.0 + */ +class CameraEventHandler : EventHandlingStrategy { + constructor() { + // Nothing to do here. + } + + /** + * Constructor as required by `Parcelable` implementation. + * + * @param in A `Parcel` that is a serialized version of a [CameraEventHandler]. + */ + @Suppress("UNUSED_PARAMETER") + private constructor(`in`: Parcel) { + // Nothing to do here. + } + + override fun handleSpaceWarning(backgroundService: BackgroundService) { + showSpaceWarningNotification(backgroundService.applicationContext) + backgroundService.stopSelf() + backgroundService.sendStoppedItselfMessage() + Log.i(TAG, "handleSpaceWarning() - CS capturing stopped.") + } + + override fun handleCameraAccessLostWarning(backgroundService: BackgroundService) { + showCameraAccessLostNotification(backgroundService.applicationContext) + backgroundService.stopSelf() + backgroundService.sendStoppedItselfMessage() + Log.i(TAG, "handleCameraAccessLostWarning() triggered - CS capturing stopped.") + } + + override fun handleCameraErrorWarning( + backgroundService: BackgroundService, + reason: String + ) { + showCameraErrorNotification(backgroundService.applicationContext, reason) + + // The CameraStateHandle throws a hard exception for play store statistics but + // we try to stop this service here gracefully anyway + backgroundService.stopSelf() + backgroundService.sendStoppedItselfMessage() + Log.i(TAG, "handleCameraErrorWarning() triggered - CS capturing stopped.") + } + + override fun handlePictureCapturingDecrease(backgroundService: BackgroundService) { + Log.i(TAG, "handlePictureCapturingDecrease() triggered. Showing notification.") + showPictureCapturingDecreasedNotification(backgroundService.applicationContext) + } + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) {} + + /** + * A [Notification] shown when the [BackgroundService] triggered the low space event. + * + * @param context The context if the service used to show the [Notification]. It stays even + * when the service is stopped as long as a unique id is used. + */ + private fun showSpaceWarningNotification(context: Context) { + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(notificationManager) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, NOTIFICATION_CHANNEL_ID_WARNING, + context.getString(de.cyface.app.utils.R.string.notification_channel_name_warning), + context.getString(de.cyface.app.utils.R.string.notification_channel_description_warning), + NotificationManager.IMPORTANCE_HIGH, true, Color.RED, true + ) + } + // TODO: see if we not create two of those warnings (DCS and CS) + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID_WARNING + ).setContentIntent(onClickPendingIntent) + .setSmallIcon(R.drawable.ic_logo_only_c) + .setContentTitle(context.getString(de.cyface.app.utils.R.string.notification_title_capturing_stopped)) + .setContentText(context.getString(de.cyface.app.utils.R.string.error_message_capturing_canceled_no_space)) + .setOngoing(false).setWhen(System.currentTimeMillis()).setPriority(2) + .setAutoCancel(true) + .setVibrate(longArrayOf(500, 1500)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + notificationManager.notify(SPACE_WARNING_NOTIFICATION_ID, notificationBuilder.build()) + } + + /** + * A [Notification] shown when the [BackgroundService] triggered the 'camera error' event. + * + * @param context The context if the service used to show the [Notification]. It stays even + * when the service is stopped as long as a unique id is used. + */ + private fun showCameraErrorNotification(context: Context, reason: String) { + + // Open Activity when the notification is clicked + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(notificationManager) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, NOTIFICATION_CHANNEL_ID_WARNING, + context.getString(de.cyface.app.utils.R.string.notification_channel_name_warning), + context.getString(de.cyface.app.utils.R.string.notification_channel_description_warning), + NotificationManager.IMPORTANCE_HIGH, true, Color.RED, true + ) + } + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID_WARNING + ).setContentIntent(onClickPendingIntent) + .setSmallIcon(R.drawable.ic_logo_only_c) + .setContentTitle(context.getString(de.cyface.app.utils.R.string.notification_title_capturing_stopped)) + .setContentText(reason) + .setOngoing(false).setWhen(System.currentTimeMillis()).setPriority(2) + .setAutoCancel(true) + .setVibrate(longArrayOf(500, 1500)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + notificationManager.notify(CAMERA_ACCESS_LOST_NOTIFICATION_ID, notificationBuilder.build()) + } + + /** + * A [Notification] shown when the [BackgroundService] triggered the camera access lost event. + * + * @param context The context if the service used to show the [Notification]. It stays even + * when the service is stopped as long as a unique id is used. + */ + private fun showCameraAccessLostNotification(context: Context) { + + // Open Activity when the notification is clicked + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(notificationManager) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, NOTIFICATION_CHANNEL_ID_WARNING, + context.getString(de.cyface.app.utils.R.string.notification_channel_name_warning), + context.getString(de.cyface.app.utils.R.string.notification_channel_description_warning), + NotificationManager.IMPORTANCE_HIGH, true, Color.RED, true + ) + } + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID_WARNING + ).setContentIntent(onClickPendingIntent) + .setSmallIcon(R.drawable.ic_logo_only_c) + .setContentTitle(context.getString(de.cyface.app.utils.R.string.notification_title_capturing_stopped)) + .setContentText( + context.getString(de.cyface.camera_service.R.string.notification_text_capturing_stopped_camera_disconnected) + ) + .setOngoing(false).setWhen(System.currentTimeMillis()).setPriority(2) + .setAutoCancel(true) + .setVibrate(longArrayOf(500, 1500)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + notificationManager.notify(CAMERA_ACCESS_LOST_NOTIFICATION_ID, notificationBuilder.build()) + } + + /** + * A [Notification] shown when the [BackgroundService] triggered the picture capturing slowed down + * event. + * + * @param context The context if the service used to show the [Notification]. It stays even + * when the service is stopped as long as a unique id is used. + */ + private fun showPictureCapturingDecreasedNotification(context: Context) { + + // Open Activity when the notification is clicked + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(notificationManager) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, NOTIFICATION_CHANNEL_ID_WARNING, + context.getString(de.cyface.app.utils.R.string.notification_channel_name_warning), + context.getString(de.cyface.app.utils.R.string.notification_channel_description_warning), + NotificationManager.IMPORTANCE_HIGH, true, Color.RED, true + ) + } + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID_WARNING + ).setContentIntent(onClickPendingIntent) + .setSmallIcon(R.drawable.ic_logo_only_c) + .setContentTitle(context.getString(R.string.notification_title_picture_capturing_decreased)) + .setContentText( + context.getString(R.string.notification_text_picture_capturing_decreased) + ) + .setOngoing(false).setWhen(System.currentTimeMillis()).setPriority(2) + .setAutoCancel(true) + .setVibrate(longArrayOf(500, 1500)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + notificationManager.notify( + PICTURE_CAPTURING_DECREASED_NOTIFICATION_ID, + notificationBuilder.build() + ) + } + + override fun buildCapturingNotification( + context: BackgroundService, + isVideoModeRequested: Boolean + ): Notification { + Validate.notNull(context, "No context provided!") + val channelId: String = NOTIFICATION_CHANNEL_ID_RUNNING + + // Open Activity when the notification is clicked + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, channelId, "Cyface", + context.getString(de.cyface.datacapturing.R.string.notification_channel_description_running), + NotificationManager.IMPORTANCE_LOW, false, Color.GREEN, false + ) + } + val builder = NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getText(R.string.camera_capturing_active)) + .setContentIntent(onClickPendingIntent).setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setAutoCancel(false) + + // 2019-07 update: Android Studio now seems to solve this automatically: + // - "Image Asset" > "Notification Icon" generates PNGs + a vector in drawable-anydpi-v24 + + // APIs < 21 crash when replacing a notification icon with a vector icon + // What works is: use a png to replace the icon on API < 21 or to reuse the same vector icon + // The most elegant solution seems to be to have PNGs for the icons and the vector xml in drawable-anydpi-v21, + // see https://stackoverflow.com/a/37334176/5815054 + builder.setSmallIcon(if (isVideoModeRequested) R.drawable.ic_videocam else R.drawable.ic_photo_camera) + return builder.build() + } + + companion object { + /** + * The `Parcelable` creator as required by the Android Parcelable specification. + */ + @Suppress("unused") + @JvmField + val CREATOR: Creator = object : Creator { + override fun createFromParcel(`in`: Parcel): CameraEventHandler { + return CameraEventHandler(`in`) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + /** + * Since Android 8 it is necessary to create a new notification channel for a foreground service notification. To + * save system resources this should only happen if the channel does not exist. This method does just that. + * + * @param context The Android `Context` to use to create the notification channel. + * @param channelId The identifier of the created or existing channel. + */ + private fun createNotificationChannelIfNotExists( + context: Context, + channelId: String, channelName: String, description: String, + importance: Int, enableLights: Boolean, lightColor: Int, enableVibration: Boolean + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(manager, "Manager for service notifications not available.") + if (manager.getNotificationChannel(channelId) == null) { + val channel = NotificationChannel(channelId, channelName, importance) + channel.description = description + channel.enableLights(enableLights) + channel.enableVibration(enableVibration) + channel.lightColor = lightColor + manager.createNotificationChannel(channel) + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/DataCapturingEventHandler.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/DataCapturingEventHandler.kt new file mode 100644 index 00000000..7edd1e81 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/notification/DataCapturingEventHandler.kt @@ -0,0 +1,212 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.RingtoneManager +import android.os.Build +import android.os.Parcel +import android.os.Parcelable.Creator +import android.util.Log +import androidx.core.app.NotificationCompat +import de.cyface.app.digural.MainActivity +import de.cyface.app.digural.R +import de.cyface.app.digural.utils.Constants.TAG +import de.cyface.app.utils.SharedConstants.NOTIFICATION_CHANNEL_ID_RUNNING +import de.cyface.app.utils.SharedConstants.NOTIFICATION_CHANNEL_ID_WARNING +import de.cyface.app.utils.SharedConstants.SPACE_WARNING_NOTIFICATION_ID +import de.cyface.datacapturing.EventHandlingStrategy +import de.cyface.datacapturing.backend.DataCapturingBackgroundService +import de.cyface.utils.Validate + +/** + * A [EventHandlingStrategy] to respond to specified events triggered by the + * [DataCapturingBackgroundService]. + * + * @author Armin Schnabel + * @author Klemens Muthmann + * @version 3.0.2 + * @since 2.5.0 + */ +class DataCapturingEventHandler : EventHandlingStrategy { + constructor() { + // Nothing to do here. + } + + /** + * Constructor as required by `Parcelable` implementation. + * + * @param in A `Parcel` that is a serialized version of a `DataCapturingEventHandler`. + */ + @Suppress("UNUSED_PARAMETER") + private constructor(`in`: Parcel) { + // Nothing to do here. + } + + override fun handleSpaceWarning(dataCapturingBackgroundService: DataCapturingBackgroundService) { + showSpaceWarningNotification(dataCapturingBackgroundService.applicationContext) + dataCapturingBackgroundService.stopSelf() + dataCapturingBackgroundService.sendStoppedItselfMessage() + Log.i(TAG, "Low space event triggered - DCS capturing stopped.") + } + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) {} + + /** + * A [Notification] shown when the [DataCapturingBackgroundService] triggered the low space event. + * + * @param context The context if the service used to show the [Notification]. It stays even + * when the service is stopped as long as a unique id is used. + */ + private fun showSpaceWarningNotification(context: Context) { + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + val notificationManager = context + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(notificationManager) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, NOTIFICATION_CHANNEL_ID_WARNING, + context.getString(de.cyface.app.utils.R.string.notification_channel_name_warning), + context.getString(de.cyface.app.utils.R.string.notification_channel_description_warning), + NotificationManager.IMPORTANCE_HIGH, true, Color.RED, true + ) + } + val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( + context, + NOTIFICATION_CHANNEL_ID_WARNING + ).setContentIntent(onClickPendingIntent) + .setSmallIcon(R.drawable.ic_logo_only_c) + .setContentTitle(context.getString(de.cyface.app.utils.R.string.notification_title_capturing_stopped)) + .setContentText(context.getString(de.cyface.app.utils.R.string.error_message_capturing_canceled_no_space)) + .setOngoing(false).setWhen(System.currentTimeMillis()).setPriority(2) + .setAutoCancel(true) + .setVibrate(longArrayOf(500, 1500)) + .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)) + notificationManager.notify(SPACE_WARNING_NOTIFICATION_ID, notificationBuilder.build()) + } + + override fun buildCapturingNotification(context: DataCapturingBackgroundService): Notification { + Validate.notNull(context, "No context provided!") + val channelId: String = NOTIFICATION_CHANNEL_ID_RUNNING + + // Open Activity when the notification is clicked + val onClickIntent = Intent(context, MainActivity::class.java) + val onClickPendingIntent: PendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } else { + // Ignore warning: immutable flag only available in API >= 23, see above + PendingIntent.getActivity( + context, 0, onClickIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + createNotificationChannelIfNotExists( + context, channelId, "Cyface", + context.getString(de.cyface.datacapturing.R.string.notification_channel_description_running), + NotificationManager.IMPORTANCE_LOW, false, Color.GREEN, false + ) + } + val builder = NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getText(de.cyface.datacapturing.R.string.capturing_active)) + .setContentIntent(onClickPendingIntent).setWhen(System.currentTimeMillis()) + .setOngoing(true) + .setAutoCancel(false) + + // APIs < 21 crash when replacing a notification icon with a vector icon + // What works is: use a png to replace the icon on API < 21 or to reuse the same vector icon + // The most elegant solution seems to be to have PNGs for the icons and the vector xml in drawable-anydpi-v21, + // see https://stackoverflow.com/a/37334176/5815054 + builder.setSmallIcon(R.drawable.ic_logo_only_c) + return builder.build() + } + + companion object { + /** + * The `Parcelable` creator as required by the Android Parcelable specification. + */ + @Suppress("unused") + @JvmField + val CREATOR: Creator = + object : Creator { + override fun createFromParcel(`in`: Parcel): DataCapturingEventHandler { + return DataCapturingEventHandler(`in`) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + /** + * Since Android 8 it is necessary to create a new notification channel for a foreground service notification. To + * save system resources this should only happen if the channel does not exist. This method does just that. + * + * @param context The Android `Context` to use to create the notification channel. + * @param channelId The identifier of the created or existing channel. + */ + private fun createNotificationChannelIfNotExists( + context: Context, + channelId: String, channelName: String, description: String, + importance: Int, enableLights: Boolean, lightColor: Int, enableVibration: Boolean + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + Validate.notNull(manager, "Manager for service notifications not available.") + if (manager.getNotificationChannel(channelId) == null) { + val channel = NotificationChannel(channelId, channelName, importance) + channel.description = description + channel.enableLights(enableLights) + channel.enableVibration(enableVibration) + channel.lightColor = lightColor + manager.createNotificationChannel(channel) + } + } + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/CyfaceFileObserver.java b/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/CyfaceFileObserver.java new file mode 100644 index 00000000..dcd59f2e --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/CyfaceFileObserver.java @@ -0,0 +1,351 @@ +package de.cyface.app.digural.storage;/* + * Copyright 2017 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +/*package de.cyface.app.storage; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Stack; + +import android.os.FileObserver; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import de.cyface.persistence.model.Measurement; + +/** + * Observes which measurement exist on the phone to be displayed to the user. + *

+ * You may start this observer by calling {@link #startWatching()} and you should stop it by calling + * {@link #stopWatching()}. Changes to the measurements are handed directly to the LiveData instances + * returned by {@link #getCorruptedMeasurements()}, {@link #getFinishedMeasurements()}, {@link #getOpenMeasurements()} + * and {@link #getSyncedMeasurements()}. + * + * @author Armin Schnabel + * @author Klemens Muthmann + * @version 2.0.0 + * @since 3.0.0 + * / +public final class CyfaceFileObserver extends FileObserver { + + /** + * The events we want to listen to (for the folders we subscribe to). + * We don't want to be notified about accessed, opened or changed files to avoid much work load + * during capturing if the user is checking his measurements at the same time. + * / + private final static int FILE_OBSERVER_MASK = CREATE + DELETE_SELF + DELETE + MOVED_FROM + MOVED_TO; + /** + * The TAG used identify Logcat messages from an instance of this class. + * / + private static final String TAG = "de.cyface.app.m_loader"; + /** + * The parent directories for storing measurement data in different states. + * / + private final List measurementParentDirectories; + /** + * The root directory of the measurement storage area. + * / + private final String rootPath; + /** + * Instance of {@link FileUtils} used to handle CRUD operations on the measurement storage area. + * / + private final FileUtils fileUtils; + /** + * The storage for open measurements currently running. + * / + private final MeasurementsStorage openMeasurements; + /** + * The storage for already synchronized measurements, if any of their data needs to be kept around. + * / + private final MeasurementsStorage syncedMeasurements; + /** + * The storage for measurements finished but not yet synchronized. + * / + private final MeasurementsStorage finishedMeasurements; + /** + * The storage for measurements that have been corrupted. In an ideal world this does not happen, but sometimes + * there are crashes that corrupt a measurement during capturing or synchronization for example. + * / + private final MeasurementsStorage corruptedMeasurements; + /** + * The list of observers on the files in the directory containing all the measurement data. + * / + private List mObservers; + + /** + * Creates a new completely initialized MeasurementObserver with access to the stored measurements + * using the provided {@link FileUtils} instance. + * + * @param fileUtils A utility object providing access to the directory containing the measurement data. + * / + public MeasurementObserver(final @NonNull FileUtils fileUtils) { + super(fileUtils.getMeasurementsRootPath(), FILE_OBSERVER_MASK); // See + // https://stackoverflow.com/a/20609634/5815054 + this.rootPath = fileUtils.getMeasurementsRootPath(); + this.fileUtils = fileUtils; + measurementParentDirectories = new ArrayList<>(); + measurementParentDirectories.add(fileUtils.getOpenMeasurementsDirPath()); + measurementParentDirectories.add(fileUtils.getFinishedMeasurementsDirPath()); + measurementParentDirectories.add(fileUtils.getSynchronizedMeasurementsDirPath()); + measurementParentDirectories.add(fileUtils.getCorruptedMeasurementsDirPath()); + measurementParentDirectories.add(fileUtils.getMeasurementsRootPath()); + + openMeasurements = new MeasurementsStorage(); + syncedMeasurements = new MeasurementsStorage(); + finishedMeasurements = new MeasurementsStorage(); + corruptedMeasurements = new MeasurementsStorage(); + startWatching(); + } + + @Override + public void startWatching() { + if (mObservers != null) { + // Log.d(TAG, "FILE: already initialized, ignoring"); + return; + } + mObservers = new ArrayList<>(); + addToWatchList(new File(rootPath)); + } + + /** + * Adds the provided file and, if it is a directory, also all its children recursively to the files observed for + * changes by this MeasurementObserver. + * + * @param file The file to observe for changes. + * / + private void addToWatchList(final @NonNull File file) { + // Collect all (sub) dirs + Stack stack = new Stack<>(); + stack.push(file.getAbsolutePath()); + while (!stack.empty()) { + final String path = stack.pop(); + final File dir = new File(path); + final SingleFileObserver singleFileObserver = new SingleFileObserver(path); + Log.d(TAG, "FILE: Start watching: " + singleFileObserver.path); + mObservers.add(singleFileObserver); + singleFileObserver.startWatching(); + + if (!isMeasurementsParentDir(dir)) { + final String parentDirectory = dir.getParent(); + if (parentDirectory.equals(fileUtils.getOpenMeasurementsDirPath())) { + Log.d(TAG, "FILE: add open " + dir); + openMeasurements.add(dir); + } else if (parentDirectory.equals(fileUtils.getFinishedMeasurementsDirPath())) { + Log.d(TAG, "FILE: add finished " + dir); + finishedMeasurements.add(dir); + } else if (parentDirectory.equals(fileUtils.getSynchronizedMeasurementsDirPath())) { + Log.d(TAG, "FILE: add synced " + dir); + syncedMeasurements.add(dir); + } else if (parentDirectory.equals(fileUtils.getCorruptedMeasurementsDirPath())) { + Log.d(TAG, "FILE: add corrupted " + dir); + corruptedMeasurements.add(dir); + } else if (!dir.getAbsolutePath().equals(fileUtils.getMeasurementsRootPath())) { + throw new IllegalStateException("Unknown measurement state of file: " + dir); + } + } + + // Watch sub dirs + File[] subDirs = new File(path).listFiles(FileUtils.directoryFilter()); + if (subDirs == null) + continue; + for (File directory : subDirs) { + stack.push(directory.getPath()); + } + } + } + + @Override + public void stopWatching() { + if (mObservers == null) + return; + + // Stop watching all dir and sub dirs + for (int i = 0; i < mObservers.size(); ++i) { + // Log.d(TAG, "FILE: Stop watching: " + mObservers.get(i).path); + mObservers.get(i).stopWatching(); + } + + mObservers.clear(); + mObservers = null; + } + + /** + * Removes the provided file and all children from the list of watched files. + * + * @param file The file to remove from the list of watched files. + * / + private void removeFromWatchList(final @NonNull File file) { + boolean wasSuccessfullyRemoved = false; + for (SingleFileObserver observer : mObservers) { + if (observer.path.startsWith(file.getAbsolutePath())) { + wasSuccessfullyRemoved |= mObservers.remove(observer); + observer.stopWatching(); + // Log.d(TAG, "FILE: Stop watching " + observer.path); + + if (!isMeasurementsParentDir(file)) { + final String parentDirectory = file.getParent(); + if (parentDirectory.equals(fileUtils.getOpenMeasurementsDirPath())) { + Log.d(TAG, "FILE: removed open " + file); + openMeasurements.remove(file); + } else if (parentDirectory.equals(fileUtils.getFinishedMeasurementsDirPath())) { + Log.d(TAG, "FILE: removed finished " + file); + finishedMeasurements.remove(file); + return; + } else if (parentDirectory.equals(fileUtils.getSynchronizedMeasurementsDirPath())) { + Log.d(TAG, "FILE: removed synced " + file); + syncedMeasurements.remove(file); + } else if (parentDirectory.equals(fileUtils.getCorruptedMeasurementsDirPath())) { + Log.d(TAG, "FILE: removed corrupted " + file); + corruptedMeasurements.remove(file); + } else if (!file.getAbsolutePath().equals(fileUtils.getMeasurementsRootPath())) { + throw new IllegalStateException("Unknown measurement state of file: " + file); + } + } + } + } + if (!wasSuccessfullyRemoved) { + throw new IllegalStateException("Failed to remove from watch list: " + file); + } + } + + @Override + public void onEvent(int event, final String path) { + event &= FileObserver.ALL_EVENTS; // See https://stackoverflow.com/a/20609634/5815054 + + File file = new File(path); + if (file.getName().equals("null")) + file = file.getParentFile(); + final boolean isMeasurementsParentDir = isMeasurementsParentDir(file); + final boolean isMeasurementDirCreated = event == FileObserver.CREATE && file.isDirectory() + && !isMeasurementsParentDir; + final boolean isMeasurementDirDeleted = event == FileObserver.DELETE_SELF && !isMeasurementsParentDir; + final boolean isMeasurementDirMovedAway = event == FileObserver.MOVED_FROM && !isMeasurementsParentDir; + final boolean isMeasurementDirMovedTo = event == FileObserver.MOVED_TO && !isMeasurementsParentDir; + final boolean isParentDirDeleted = event == FileObserver.DELETE_SELF && isMeasurementsParentDir; + final boolean isParentDirCreated = event == FileObserver.CREATE && file.isDirectory() + && isMeasurementsParentDir; + + if (isMeasurementDirCreated || isMeasurementDirMovedTo) { + // Log.d(TAG, "FILE: Measurement created of moved to: " + file.getAbsolutePath()); + addToWatchList(file); + } else if (isMeasurementDirDeleted || isMeasurementDirMovedAway) { + // Log.d(TAG, "FILE: Measurement deleted or moved away: " + file.getAbsolutePath()); + removeFromWatchList(file); + } else if (isParentDirDeleted) { + Log.w(TAG, "FILE: parent dir was deleted ! " + file.getAbsolutePath()); + removeFromWatchList(file); + } else if (isParentDirCreated) { + Log.w(TAG, "FILE: Parent dir created: " + file.getAbsolutePath()); + addToWatchList(file); + } else { + if (event == 0 || event == FileObserver.ACCESS || event == CLOSE_NOWRITE || event == ATTRIB || event == OPEN + || event == MOVE_SELF) { + return; + } + if (event == CREATE) { + // Nothing to do yet, file created + return; + } + if (event == FileObserver.DELETE && !file.isDirectory()) { + // Nothing to do yet, file deleted + return; + } + Log.d(TAG, "FILE: UNKNOWN (" + (file.isDirectory() ? "dir" : "file") + ") event " + event + ": " + + file.getAbsolutePath()); + } + } + + /** + * Checks if the file is one of the parent directories which *contain* measurement dirs, e.g. + * open, synced, finished, corrupted. + * + * @param file The file to check + * @return True of the file one of those dirs. + * / + private boolean isMeasurementsParentDir(final File file) { + for (String parentDir : measurementParentDirectories) { + if (parentDir.equals(file.getAbsolutePath())) { + return true; + } + } + return false; + } + + /** + * @return The storage for open measurements currently running. + * / + public @NonNull LiveData> getOpenMeasurements() { + return openMeasurements.getData(); + } + + /** + * @return The storage for already synchronized measurements, if any of their data needs to be kept around. + * / + public @NonNull LiveData> getSyncedMeasurements() { + return syncedMeasurements.getData(); + } + + /** + * @return All currently finished measurements stored on this device. + * / + public @NonNull LiveData> getFinishedMeasurements() { + return finishedMeasurements.getData(); + } + + /** + * @return The storage for measurements that have been corrupted. In an ideal world this does not happen, but + * sometimes there are crashes that corrupt a measurement during capturing or synchronization for example. + * / + public @NonNull LiveData> getCorruptedMeasurements() { + return corruptedMeasurements.getData(); + } + + /** + * An observer listening for changes to a single file in the local Android file system. + * + * @author Armin Schnabel + * @author Klemens Muthmann + * @version 1.0.0 + * @since 3.0.0 + * / + private final class SingleFileObserver extends FileObserver { + /** + * The path to the file to observe. + * / + private String path; + + /** + * Creates a new completely initialized observer for a single file. + * + * @param path The path to the file to observe. + * / + SingleFileObserver(final @NonNull String path) { + super(path, FILE_OBSERVER_MASK); + this.path = path; + } + + @Override + public void onEvent(final int event, final @NonNull String path) { + String newPath = this.path + "/" + path; + MeasurementObserver.this.onEvent(event, newPath); + } + + } +}*/ diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/MeasurementStorage.java b/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/MeasurementStorage.java new file mode 100644 index 00000000..0c206941 --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/storage/MeasurementStorage.java @@ -0,0 +1,89 @@ +package de.cyface.app.digural.storage;/* + * Copyright 2017 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +/*package de.cyface.app.storage; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import de.cyface.persistence.model.Measurement; + +/** + * Represents a storage of measurements in the local file system. You can get the data in this storage as a + * LiveData object and observe changes to it, while respecting the Android lifecycle. + * + * @author Klemens Muthmann + * @version 1.0.0 + * @since 3.0.0 + * / +final class MeasurementsStorage { + /** + * A LiveData representation of the {@link Measurement} objects stored in this storage. + * / + private final MutableLiveData> data; + /** + * All the {@link Measurement} stored in this storage. + * / + private final List storedData; + + /** + * Create a new empty but completely initialized storage for {@link Measurement} objects. + * / + MeasurementsStorage() { + this.data = new MutableLiveData<>(); + this.storedData = new ArrayList<>(); + } + + /** + * @return The data contained in this storage as an Android LiveData object. + * / + @NonNull + LiveData> getData() { + return data; + } + + /** + * Adds a measurement to this storage. + * + * @param file A directory representing a measurement on the local file system. + * / + void add(final @NonNull File file) { + final Measurement measurement = new Measurement(Long.valueOf(file.getName())); + storedData.add(measurement); + data.postValue(storedData); + } + + /** + * Removes a measurement from this storage. + * + * @param file A directory representing a measurement on the local file system. + * / + void remove(final @NonNull File file) { + final Measurement measurement = new Measurement(Long.valueOf(file.getName())); + if (!storedData.remove(measurement)) { + throw new IllegalStateException("Trying to remove measurement " + measurement + + " from non existing list. There should be no measurement there for removal to begin with."); + } + data.postValue(storedData); + } +} +*/ diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/ConnectionInfo.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/ConnectionInfo.kt new file mode 100644 index 00000000..0c5a340d --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/ConnectionInfo.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2017 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.utils + +import android.content.Context +import android.net.ConnectivityManager +import de.cyface.utils.Validate + +/** + * Basic class which provides info about the connection status + * + * @author Armin Schnabel + * @version 1.0.1 + * @since 1.0.0 + * + * TODO Should move to the SDK (Wifi-Surveyor) + */ +class ConnectionInfo(private val context: Context) { + val isConnectedToWifi: Boolean + get() { + val manager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + Validate.notNull(manager) + val networkInfo = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI) + Validate.notNull(networkInfo) + return networkInfo!!.isConnected + } +} \ No newline at end of file diff --git a/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/Constants.kt b/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/Constants.kt new file mode 100644 index 00000000..8427784c --- /dev/null +++ b/ui/digural/src/main/kotlin/de/cyface/app/digural/utils/Constants.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2023 Cyface GmbH + * + * This file is part of the Cyface App for Android. + * + * The Cyface App for Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface App for Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface App for Android. If not, see . + */ +package de.cyface.app.digural.utils + +/** + * This class holds all constants required by multiple classes. This avoids unnecessary dependencies + * which would only be needed to access those constants. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @version 3.4.0 + * @since 1.0.0 + */ +object Constants { + const val PACKAGE = "de.cyface.app.digural" + const val TAG = PACKAGE // This can be references as default TAG for this app + const val SUPPORT_EMAIL = "support@cyface.de" + + /** + * must be different from other SDK using apps + */ + const val AUTHORITY = "de.cyface.app.digural.provider" + const val ACCOUNT_TYPE = "de.cyface.app.digural" +} \ No newline at end of file diff --git a/ui/digural/src/main/res/drawable-anydpi-v24/ic_photo_camera.xml b/ui/digural/src/main/res/drawable-anydpi-v24/ic_photo_camera.xml new file mode 100644 index 00000000..7a607145 --- /dev/null +++ b/ui/digural/src/main/res/drawable-anydpi-v24/ic_photo_camera.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/ui/digural/src/main/res/drawable-anydpi-v24/ic_videocam.xml b/ui/digural/src/main/res/drawable-anydpi-v24/ic_videocam.xml new file mode 100644 index 00000000..ebc6fe68 --- /dev/null +++ b/ui/digural/src/main/res/drawable-anydpi-v24/ic_videocam.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/ui/digural/src/main/res/drawable-anydpi/ic_logo_only_c.xml b/ui/digural/src/main/res/drawable-anydpi/ic_logo_only_c.xml new file mode 100644 index 00000000..061a052a --- /dev/null +++ b/ui/digural/src/main/res/drawable-anydpi/ic_logo_only_c.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/ui/digural/src/main/res/drawable-hdpi/ic_logo_only_c.png b/ui/digural/src/main/res/drawable-hdpi/ic_logo_only_c.png new file mode 100644 index 00000000..9e1d1f7c Binary files /dev/null and b/ui/digural/src/main/res/drawable-hdpi/ic_logo_only_c.png differ diff --git a/ui/digural/src/main/res/drawable-hdpi/ic_photo_camera.png b/ui/digural/src/main/res/drawable-hdpi/ic_photo_camera.png new file mode 100644 index 00000000..0873daf0 Binary files /dev/null and b/ui/digural/src/main/res/drawable-hdpi/ic_photo_camera.png differ diff --git a/ui/digural/src/main/res/drawable-hdpi/ic_videocam.png b/ui/digural/src/main/res/drawable-hdpi/ic_videocam.png new file mode 100644 index 00000000..2235f6e4 Binary files /dev/null and b/ui/digural/src/main/res/drawable-hdpi/ic_videocam.png differ diff --git a/ui/digural/src/main/res/drawable-mdpi/ic_logo_only_c.png b/ui/digural/src/main/res/drawable-mdpi/ic_logo_only_c.png new file mode 100644 index 00000000..5df6cc5e Binary files /dev/null and b/ui/digural/src/main/res/drawable-mdpi/ic_logo_only_c.png differ diff --git a/ui/digural/src/main/res/drawable-mdpi/ic_photo_camera.png b/ui/digural/src/main/res/drawable-mdpi/ic_photo_camera.png new file mode 100644 index 00000000..9e5bfece Binary files /dev/null and b/ui/digural/src/main/res/drawable-mdpi/ic_photo_camera.png differ diff --git a/ui/digural/src/main/res/drawable-mdpi/ic_videocam.png b/ui/digural/src/main/res/drawable-mdpi/ic_videocam.png new file mode 100644 index 00000000..d71c5a72 Binary files /dev/null and b/ui/digural/src/main/res/drawable-mdpi/ic_videocam.png differ diff --git a/ui/digural/src/main/res/drawable-v23/background_splash.xml b/ui/digural/src/main/res/drawable-v23/background_splash.xml new file mode 100644 index 00000000..8ffaba69 --- /dev/null +++ b/ui/digural/src/main/res/drawable-v23/background_splash.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/digural/src/main/res/drawable-xhdpi/ic_logo_only_c.png b/ui/digural/src/main/res/drawable-xhdpi/ic_logo_only_c.png new file mode 100644 index 00000000..d35b49a2 Binary files /dev/null and b/ui/digural/src/main/res/drawable-xhdpi/ic_logo_only_c.png differ diff --git a/ui/digural/src/main/res/drawable-xhdpi/ic_photo_camera.png b/ui/digural/src/main/res/drawable-xhdpi/ic_photo_camera.png new file mode 100644 index 00000000..b579a4af Binary files /dev/null and b/ui/digural/src/main/res/drawable-xhdpi/ic_photo_camera.png differ diff --git a/ui/digural/src/main/res/drawable-xhdpi/ic_videocam.png b/ui/digural/src/main/res/drawable-xhdpi/ic_videocam.png new file mode 100644 index 00000000..e9e258e7 Binary files /dev/null and b/ui/digural/src/main/res/drawable-xhdpi/ic_videocam.png differ diff --git a/ui/digural/src/main/res/drawable-xxhdpi/ic_logo_only_c.png b/ui/digural/src/main/res/drawable-xxhdpi/ic_logo_only_c.png new file mode 100644 index 00000000..c9f5f1af Binary files /dev/null and b/ui/digural/src/main/res/drawable-xxhdpi/ic_logo_only_c.png differ diff --git a/ui/digural/src/main/res/drawable-xxhdpi/ic_photo_camera.png b/ui/digural/src/main/res/drawable-xxhdpi/ic_photo_camera.png new file mode 100644 index 00000000..d350436e Binary files /dev/null and b/ui/digural/src/main/res/drawable-xxhdpi/ic_photo_camera.png differ diff --git a/ui/digural/src/main/res/drawable-xxhdpi/ic_videocam.png b/ui/digural/src/main/res/drawable-xxhdpi/ic_videocam.png new file mode 100644 index 00000000..f1822c50 Binary files /dev/null and b/ui/digural/src/main/res/drawable-xxhdpi/ic_videocam.png differ diff --git a/ui/digural/src/main/res/drawable/background_splash.xml b/ui/digural/src/main/res/drawable/background_splash.xml new file mode 100644 index 00000000..1c5b2505 --- /dev/null +++ b/ui/digural/src/main/res/drawable/background_splash.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/ui/digural/src/main/res/drawable/ic_bus.xml b/ui/digural/src/main/res/drawable/ic_bus.xml new file mode 100644 index 00000000..e16e2597 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_bus.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_circle.xml b/ui/digural/src/main/res/drawable/ic_circle.xml new file mode 100644 index 00000000..a395f731 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_directions_bike.xml b/ui/digural/src/main/res/drawable/ic_directions_bike.xml new file mode 100644 index 00000000..ded5e335 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_directions_bike.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_directions_car.xml b/ui/digural/src/main/res/drawable/ic_directions_car.xml new file mode 100644 index 00000000..6d6337c3 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_directions_car.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_logo_foreground.xml b/ui/digural/src/main/res/drawable/ic_logo_foreground.xml new file mode 100644 index 00000000..24477fd6 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_logo_foreground.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/ui/digural/src/main/res/drawable/ic_logo_text_only.xml b/ui/digural/src/main/res/drawable/ic_logo_text_only.xml new file mode 100644 index 00000000..3028e99a --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_logo_text_only.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/digural/src/main/res/drawable/ic_pause.xml b/ui/digural/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..ee3a8cfd --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_play.xml b/ui/digural/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..269f135f --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_play.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_resume.xml b/ui/digural/src/main/res/drawable/ic_resume.xml new file mode 100644 index 00000000..448e2f84 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_resume.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_stop.xml b/ui/digural/src/main/res/drawable/ic_stop.xml new file mode 100644 index 00000000..51370600 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_sync.xml b/ui/digural/src/main/res/drawable/ic_sync.xml new file mode 100644 index 00000000..b1bd75a1 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,5 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_train.xml b/ui/digural/src/main/res/drawable/ic_train.xml new file mode 100644 index 00000000..d5183ac1 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_train.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/digural/src/main/res/drawable/ic_walking.xml b/ui/digural/src/main/res/drawable/ic_walking.xml new file mode 100644 index 00000000..407d67b4 --- /dev/null +++ b/ui/digural/src/main/res/drawable/ic_walking.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/digural/src/main/res/layout/activity_login.xml b/ui/digural/src/main/res/layout/activity_login.xml new file mode 100644 index 00000000..f7bdb05b --- /dev/null +++ b/ui/digural/src/main/res/layout/activity_login.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + +