diff --git a/README.md b/README.md index d76f12d..3b95982 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -[![GitHub Downloads](https://img.shields.io/github/downloads/django-files/android-client/total?logo=github)](https://github.com/django-files/android-client/releases/latest/download/app-release.apk) +[![GitHub Downloads](https://img.shields.io/github/downloads/django-files/android-client/total?logo=android)](https://github.com/django-files/android-client/releases/latest/download/app-release.apk) [![GitHub Release Version](https://img.shields.io/github/v/release/django-files/android-client?logo=github)](https://github.com/django-files/android-client/releases/latest) [![Lint](https://img.shields.io/github/actions/workflow/status/django-files/android-client/lint.yaml?logo=github&logoColor=white&label=lint)](https://github.com/django-files/android-client/actions/workflows/lint.yaml) -[![GitHub Top Language](https://img.shields.io/github/languages/top/django-files/android-client?logo=htmx)](https://github.com/django-files/android-client) [![GitHub Last Commit](https://img.shields.io/github/last-commit/django-files/android-client?logo=github&label=updated)](https://github.com/django-files/android-client/graphs/commit-activity) [![GitHub Repo Size](https://img.shields.io/github/repo-size/django-files/android-client?logo=bookstack&logoColor=white&label=repo%20size)](https://github.com/django-files/android-client) +[![GitHub Top Language](https://img.shields.io/github/languages/top/django-files/android-client?logo=htmx)](https://github.com/django-files/android-client) [![GitHub Discussions](https://img.shields.io/github/discussions/django-files/android-client)](https://github.com/django-files/android-client/discussions) [![GitHub Forks](https://img.shields.io/github/forks/django-files/android-client?style=flat&logo=github)](https://github.com/django-files/android-client/forks) [![GitHub Repo Stars](https://img.shields.io/github/stars/django-files/android-client?style=flat&logo=github)](https://github.com/django-files/android-client/stargazers) @@ -59,16 +59,14 @@ _If you are unsure how to install, [Obtainium](https://github.com/ImranR98/Obtai -_Note: Until published on the play store, you may need to allow installation of apps from unknown sources._ - -- Supports Android 8 (API 26) 2017 + - -Downloading and Installing the [apk](https://github.com/django-files/android-client/releases/latest/download/app-release.apk) -should take you to the settings area to allow installation if not already enabled. -For more information, see [Release through a website](https://developer.android.com/studio/publish#publishing-website). +_Note: If installing directly, you may need to allow installation of apps from unknown sources. +For more information, see [Release through a website](https://developer.android.com/studio/publish#publishing-website)._
View Manual Steps to Install from Unknown Sources +Note: Downloading and Installing the [apk](https://github.com/django-files/android-client/releases/latest/download/app-release.apk) +should take you to the settings area to allow installation if not already enabled. Otherwise: + 1. Go to your device settings. 2. Search for "Install unknown apps" or similar. 3. Choose the app you will install the apk file from. diff --git a/app/src/main/java/com/djangofiles/djangofiles/MainActivity.kt b/app/src/main/java/com/djangofiles/djangofiles/MainActivity.kt index aa0d30f..d5ffc18 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/MainActivity.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/MainActivity.kt @@ -45,7 +45,6 @@ import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.djangofiles.djangofiles.databinding.ActivityMainBinding import com.djangofiles.djangofiles.db.Server @@ -53,15 +52,13 @@ import com.djangofiles.djangofiles.db.ServerDao import com.djangofiles.djangofiles.db.ServerDatabase import com.djangofiles.djangofiles.ui.home.HomeViewModel import com.djangofiles.djangofiles.widget.WidgetProvider -import com.djangofiles.djangofiles.work.DAILY_WORKER_CONSTRAINTS -import com.djangofiles.djangofiles.work.DailyWorker +import com.djangofiles.djangofiles.work.enqueueWorkRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.io.File -import java.net.URL import java.util.UUID -import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity() { @@ -202,40 +199,34 @@ class MainActivity : AppCompatActivity() { window.setNavigationBarContrastEnforced(false) } - // Set Nav Header Top Padding + // Update Header Padding val headerView = binding.navView.getHeaderView(0) - ViewCompat.setOnApplyWindowInsetsListener(headerView) { v, insets -> - val bars = insets.getInsets(WindowInsetsCompat.Type.statusBars()) - Log.d("ViewCompat", "top: ${bars.top}") - v.updatePadding(top = bars.top) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + Log.d("ViewCompat", "binding.root: top: ${bars.top}") + if (bars.top > 0) { + headerView.updatePadding(top = bars.top) + } insets } // Update Header Text - val packageInfo = packageManager.getPackageInfo(this.packageName, 0) + val packageInfo = packageManager.getPackageInfo(packageName, 0) val versionName = packageInfo.versionName Log.d("Main[onCreate]", "versionName: $versionName") val versionTextView = headerView.findViewById(R.id.header_version) versionTextView.text = "v${versionName}" - // TODO: Improve initialization of the WorkRequest + // Work Manager val workInterval = preferences.getString("work_interval", null) ?: "0" - Log.i("Main[onCreate]", "workInterval: $workInterval") + Log.d("Main[onCreate]", "workInterval: $workInterval") + // NOTE: This just ensures work manager is enabled or disabled based on preference if (workInterval != "0") { - val workRequest = - PeriodicWorkRequestBuilder(workInterval.toLong(), TimeUnit.MINUTES) - .setConstraints(DAILY_WORKER_CONSTRAINTS) - .build() - Log.i("Main[onCreate]", "workRequest: $workRequest") - WorkManager.getInstance(this).enqueueUniquePeriodicWork( - "daily_worker", - ExistingPeriodicWorkPolicy.KEEP, - workRequest - ) + enqueueWorkRequest(workInterval, ExistingPeriodicWorkPolicy.KEEP) } else { // TODO: Confirm this is necessary... Log.i("Main[onCreate]", "Ensuring Work is Disabled") - WorkManager.getInstance(this).cancelUniqueWork("app_worker") + WorkManager.getInstance(this).cancelUniqueWork("daily_worker") } // Handle Custom Navigation Items @@ -418,8 +409,8 @@ class MainActivity : AppCompatActivity() { Log.d("onNewIntent", "SEND TEXT DETECTED") //if (extraText.lowercase().startsWith("http")) { //if (Patterns.WEB_URL.matcher(extraText).matches()) { - if (isURL(extraText)) { - Log.d("onNewIntent", "URL DETECTED: $extraText") + if (isTextUrl(extraText)) { + Log.i("onNewIntent", "URL DETECTED: $extraText") val bundle = Bundle().apply { putString("url", extraText) } navController.navigate( R.id.nav_item_short, bundle, NavOptions.Builder() @@ -662,6 +653,15 @@ class MainActivity : AppCompatActivity() { } } + private fun isTextUrl(input: String): Boolean { + val url = input.toHttpUrlOrNull() ?: return false + if (input != url.toString()) return false + if (url.scheme !in listOf("http", "https")) return false + if (url.host.isBlank()) return false + if (url.toString().length > 2048) return false + return true + } + fun setDrawerLockMode(enabled: Boolean) { Log.d("setDrawerLockMode", "enabled: $enabled") val lockMode = @@ -709,14 +709,3 @@ fun copyToClipboard(context: Context, text: String, msg: String? = null) { clipboard.setPrimaryClip(clip) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } - -fun isURL(url: String): Boolean { - return try { - URL(url) - Log.d("isURL", "TRUE") - true - } catch (_: Exception) { - Log.d("isURL", "FALSE") - false - } -} diff --git a/app/src/main/java/com/djangofiles/djangofiles/api/FeedbackApi.kt b/app/src/main/java/com/djangofiles/djangofiles/api/FeedbackApi.kt index a93ec6c..b8d9c0a 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/api/FeedbackApi.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/api/FeedbackApi.kt @@ -29,12 +29,12 @@ class FeedbackApi(val context: Context) { } suspend fun sendFeedback(messageText: String): Response { - val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName Log.d("sendFeedback", "messageText: $messageText") + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val feedbackText = context.getString( R.string.feedback_message, context.getString(R.string.app_name), - versionName, + packageInfo.versionName, Build.VERSION.SDK_INT.toString(), messageText ) @@ -51,8 +51,7 @@ class FeedbackApi(val context: Context) { @JsonClass(generateAdapter = true) data class Message( - @Json(name = "content") - val content: String + @param:Json(name = "content") val content: String ) interface ApiService { diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt index 5d8b475..7044cf9 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/login/LoginFragment.kt @@ -18,11 +18,11 @@ import androidx.navigation.fragment.findNavController import com.djangofiles.djangofiles.R import com.djangofiles.djangofiles.ServerApi import com.djangofiles.djangofiles.databinding.FragmentLoginBinding -import com.djangofiles.djangofiles.isURL import com.google.android.material.bottomnavigation.BottomNavigationView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull class LoginFragment : Fragment() { @@ -92,7 +92,10 @@ class LoginFragment : Fragment() { binding.hostnameText.setSelection(binding.hostnameText.text.length) } Log.d("loginFunction", "host: $host") - if (!isURL(host)) { + // TODO: Look into the usage of (host.toHttpUrlOrNull() == null) here. + // NOTE: This seems to be less restrictive so should work and can be improved... + //if (!isURL(host)) { + if (host.toHttpUrlOrNull() == null) { binding.hostnameText.error = "Invalid Hostname" return@OnClickListener } @@ -190,16 +193,63 @@ class LoginFragment : Fragment() { } private fun parseHost(urlString: String): String { - var url = urlString.trim() - if (url.isEmpty()) { + try { + var url = urlString.trim() + if (url.isEmpty()) { + return "" + } + if (!url.lowercase().startsWith("http")) { + url = "https://$url" + } + if (url.toHttpUrlOrNull() == null) { + return url + } + val uri = url.toUri() + Log.d("parseHost", "uri: $uri") + Log.d("parseHost", "uri.scheme: ${uri.scheme}") + if (uri.scheme.isNullOrEmpty()) { + return "https://" + } + Log.d("parseHost", "uri.host: ${uri.host}") + if (uri.host.isNullOrEmpty()) { + return "${uri.scheme}://" + } + Log.d("parseHost", "uri.path: ${uri.path}") + val result = "${uri.scheme}://${uri.host}${uri.path}" + Log.i("parseHost", "result: $result") + return if (result.endsWith("/")) { + result.dropLast(1) + } else { + result + } + } catch (e: Throwable) { + Log.d("parseHost", "Exception: $e") return "" } - if (!url.lowercase().startsWith("http")) { - url = "https://$url" - } - if (url.endsWith("/")) { - url = url.substring(0, url.length - 1) - } - return url } + + //private fun parseHost(urlString: String): String { + // var url = urlString.trim() + // if (url.isEmpty()) { + // return "" + // } + // if (!url.lowercase().startsWith("http")) { + // url = "https://$url" + // } + // if (url.endsWith("/")) { + // url = url.substring(0, url.length - 1) + // } + // return url + //} + + //private fun isURL(url: String): Boolean { + // return try { + // URL(url) + // Log.d("isURL", "TRUE") + // true + // } catch (_: Exception) { + // Log.d("isURL", "FALSE") + // false + // } + //} } diff --git a/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt b/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt index e453511..89cd466 100644 --- a/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/djangofiles/djangofiles/ui/settings/SettingsFragment.kt @@ -29,16 +29,13 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.SeekBarPreference import androidx.preference.SwitchPreferenceCompat -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.djangofiles.djangofiles.R import com.djangofiles.djangofiles.api.FeedbackApi import com.djangofiles.djangofiles.db.Server import com.djangofiles.djangofiles.db.ServerDao import com.djangofiles.djangofiles.db.ServerDatabase -import com.djangofiles.djangofiles.work.DAILY_WORKER_CONSTRAINTS -import com.djangofiles.djangofiles.work.DailyWorker +import com.djangofiles.djangofiles.work.enqueueWorkRequest import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.firebase.Firebase @@ -47,7 +44,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit class SettingsFragment : PreferenceFragmentCompat() { @@ -115,12 +111,25 @@ class SettingsFragment : PreferenceFragmentCompat() { } } - // Background Update Interval + // Update Interval val workInterval = findPreference("work_interval") + updateWorkIntervalSettings(workInterval?.value) workInterval?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() workInterval?.setOnPreferenceChangeListener { _, newValue -> - Log.d("work_interval", "newValue: $newValue") - ctx.updateWorkManager(workInterval, newValue) + Log.d("work_interval", "newValue: ${newValue as String}") + updateWorkIntervalSettings(newValue) + ctx.updateWorkManager(newValue, workInterval.value) + } + + // Updates on Metered Connection + val workMeteredPref = findPreference("work_metered") + workMeteredPref?.setOnPreferenceChangeListener { _, newValue -> + Log.d("work_metered", "newValue: $newValue") + val result = newValue as Boolean + Log.d("work_metered", "result: $result") + workMeteredPref.isChecked = result + ctx.enqueueWorkRequest() + false } // Background Restriction @@ -248,40 +257,70 @@ class SettingsFragment : PreferenceFragmentCompat() { buildServerList() } - fun Context.updateWorkManager(listPref: ListPreference, newValue: Any): Boolean { - Log.d("updateWorkManager", "listPref: ${listPref.value} - newValue: $newValue") - val value = newValue as? String - Log.d("updateWorkManager", "String value: $value") - if (value.isNullOrEmpty()) { - Log.w("updateWorkManager", "NULL OR EMPTY - false") + private fun updateWorkIntervalSettings(selectedValue: String?) { + Log.d("updateWorkIntervalSettings", "selectedValue: $selectedValue") + if (selectedValue != null) { + val enabled = selectedValue != "0" + Log.d("updateWorkIntervalSettings", "enabled: $enabled") + findPreference("work_metered")?.isEnabled = enabled + } + } + + private fun Context.updateWorkManager(newValue: String?, curValue: String? = null): Boolean { + Log.i("updateWorkManager", "newValue: $newValue - curValue: $curValue") + if (newValue.isNullOrEmpty()) { + Log.w("updateWorkManager", "newValue.isNullOrEmpty() - false") return false - } else if (listPref.value == value) { - Log.i("updateWorkManager", "NO CHANGE - false") + } else if (curValue == newValue) { + Log.i("updateWorkManager", "curValue == newValue - false") return false } else { - Log.i("updateWorkManager", "RESCHEDULING WORK - true") - val interval = value.toLongOrNull() - Log.i("updateWorkManager", "interval: $interval") - if (interval == null || interval == 0L) { - Log.i("updateWorkManager", "DISABLING WORK") + Log.d("updateWorkManager", "ELSE - RESCHEDULING WORK - true") + if (newValue == "0" || newValue.toLongOrNull() == null) { + Log.i("updateWorkManager", "DISABLING WORK - newValue is 0 or null") WorkManager.getInstance(this).cancelUniqueWork("daily_worker") return true } else { - val newRequest = - PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) - .setInitialDelay(1, TimeUnit.MINUTES) - .setConstraints(DAILY_WORKER_CONSTRAINTS) - .build() - WorkManager.getInstance(this).enqueueUniquePeriodicWork( - "daily_worker", - ExistingPeriodicWorkPolicy.REPLACE, - newRequest - ) + enqueueWorkRequest(newValue) return true } } } +// fun Context.updateWorkManager(listPref: ListPreference, newValue: Any): Boolean { +// Log.d("updateWorkManager", "listPref: ${listPref.value} - newValue: $newValue") +// val value = newValue as? String +// Log.d("updateWorkManager", "String value: $value") +// if (value.isNullOrEmpty()) { +// Log.w("updateWorkManager", "NULL OR EMPTY - false") +// return false +// } else if (listPref.value == value) { +// Log.i("updateWorkManager", "NO CHANGE - false") +// return false +// } else { +// Log.i("updateWorkManager", "RESCHEDULING WORK - true") +// val interval = value.toLongOrNull() +// Log.i("updateWorkManager", "interval: $interval") +// if (interval == null || interval == 0L) { +// Log.i("updateWorkManager", "DISABLING WORK") +// WorkManager.getInstance(this).cancelUniqueWork("daily_worker") +// return true +// } else { +// val newRequest = +// PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) +// .setInitialDelay(1, TimeUnit.MINUTES) +// .setConstraints(DAILY_WORKER_CONSTRAINTS) +// .build() +// WorkManager.getInstance(this).enqueueUniquePeriodicWork( +// "daily_worker", +// ExistingPeriodicWorkPolicy.REPLACE, +// newRequest +// ) +// return true +// } +// } +// } + fun Context.toggleAnalytics(switchPreference: SwitchPreferenceCompat, newValue: Any) { Log.d("toggleAnalytics", "newValue: $newValue") if (newValue as Boolean) { diff --git a/app/src/main/java/com/djangofiles/djangofiles/work/DailyWorkManager.kt b/app/src/main/java/com/djangofiles/djangofiles/work/DailyWorkManager.kt new file mode 100644 index 0000000..af4487d --- /dev/null +++ b/app/src/main/java/com/djangofiles/djangofiles/work/DailyWorkManager.kt @@ -0,0 +1,56 @@ +package com.djangofiles.djangofiles.work + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +fun Context.enqueueWorkRequest( + workInterval: String? = null, + existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, +) { + Log.i("AppWorkManager", "enqueueWorkRequest: $existingPeriodicWorkPolicy") + val preferences = PreferenceManager.getDefaultSharedPreferences(this) + //val interval = (preferences.getString("work_interval", null) ?: "0").toLong() + val interval = workInterval?.toLongOrNull() + ?: preferences.getString("work_interval", null)?.toLongOrNull() ?: 0 + Log.i("AppWorkManager", "interval: $interval") + if (interval < 15) { + Log.i("AppWorkManager", "RETURN on interval < 15") + return + } + val workRequestBuilder = + PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) + .setConstraints(getWorkerConstraints(preferences)) + if (existingPeriodicWorkPolicy == ExistingPeriodicWorkPolicy.UPDATE) { + Log.i("AppWorkManager", "workRequestBuilder.setInitialDelay: $interval") + workRequestBuilder.setInitialDelay(interval, TimeUnit.MINUTES) + } + //val workRequest = workRequestBuilder.build() + //Log.i("AppWorkManager", "workRequest: $workRequest") + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "daily_worker", + existingPeriodicWorkPolicy, + workRequestBuilder.build(), + ) +} + +fun getWorkerConstraints(preferences: SharedPreferences): Constraints { + val workMetered = preferences.getBoolean("work_metered", false) + Log.d("AppWorkManager", "getWorkerConstraints: workMetered: $workMetered") + val networkType = if (workMetered) NetworkType.CONNECTED else NetworkType.UNMETERED + Log.d("AppWorkManager", "getWorkerConstraints: networkType: $networkType") + return Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresCharging(false) + .setRequiresDeviceIdle(false) + .setRequiredNetworkType(networkType) + .build() +} + diff --git a/app/src/main/java/com/djangofiles/djangofiles/work/WorkConstants.kt b/app/src/main/java/com/djangofiles/djangofiles/work/WorkConstants.kt deleted file mode 100644 index 02e01e7..0000000 --- a/app/src/main/java/com/djangofiles/djangofiles/work/WorkConstants.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.djangofiles.djangofiles.work - -import androidx.work.Constraints -import androidx.work.NetworkType - -val DAILY_WORKER_CONSTRAINTS: Constraints = Constraints.Builder() - .setRequiresBatteryNotLow(true) - .setRequiresCharging(false) - .setRequiresDeviceIdle(false) - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() diff --git a/app/src/main/res/layout/dialog_app_info.xml b/app/src/main/res/layout/dialog_app_info.xml index e1036dd..846e265 100644 --- a/app/src/main/res/layout/dialog_app_info.xml +++ b/app/src/main/res/layout/dialog_app_info.xml @@ -1,15 +1,25 @@ - + + - - - - - - - - - + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_feedback.xml b/app/src/main/res/layout/dialog_feedback.xml index 52f1c72..9025343 100644 --- a/app/src/main/res/layout/dialog_feedback.xml +++ b/app/src/main/res/layout/dialog_feedback.xml @@ -1,37 +1,54 @@ - + + + android:layout_marginBottom="12dp" + android:textAppearance="?attr/textAppearanceHeadline5" + android:text="Send Feedback" /> - + android:layout_marginBottom="12dp" + android:clipChildren="false"> + + + + diff --git a/app/src/main/res/layout/fragment_authorize.xml b/app/src/main/res/layout/fragment_authorize.xml index ae1b64d..01c32ca 100644 --- a/app/src/main/res/layout/fragment_authorize.xml +++ b/app/src/main/res/layout/fragment_authorize.xml @@ -4,143 +4,138 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:fillViewport="true"> + android:fillViewport="true" + tools:ignore="HardcodedText"> + + - + android:orientation="vertical" + android:gravity="center_horizontal" + android:padding="24dp" + tools:ignore="UselessParent"> + + + + + + - - - + android:visibility="visible" + tools:visibility="visible"> - - + android:indeterminate="true" + app:layout_constraintTop_toTopOf="@+id/refresh_layout" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + - + - + +