Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
actionbar_sync = Refresh account
actionbar_upload_from_apps = Content from other apps
actionbar_upload_files = Files
actionbar_descending = Descending
drawer_item_all_files = All files
drawer_item_shared_by_link_files = Shared by link
drawer_item_settings = Settings
drawer_item_uploads_list = Uploads
drawer_exceeded_quota = Storage full
drawer_nearing_quota = Storage nearly full
drawer_critical_quota = Storage very nearly full
prefs_category_general = General
prefs_accounts = Accounts
prefs_manage_accounts = Manage accounts
prefs_camera_upload_on_charging_summary = Enable wifi only uploads to activate this option
prefs_automatic_uploads_not_available_users_light = Automatic uploads are not available for users light
prefs_log_title = Enable logging
prefs_log_summary = This is used to log problems.
prefs_log_title_history = Logging history
prefs_log_summary_history = This shows the recorded logs.
prefs_log_delete_history_button = Delete history
prefs_remember_last_share_location = Remember share location
prefs_remember_last_upload_location_summary = Remember last share upload location
pattern_error_set = An error occurred while setting the pattern lock.
pattern_error_remove = An error occurred while removing the pattern lock.
auth_host_url = Server address https://…
uploader_error_message_received_piece_of_text = %1$s cannot upload a piece of text as a file.
light_users_subtitle = Personal space is not available
file_list_loading = Loading…
local_file_list_empty = There are no files in this folder.
local_file_list_empty_just_folders = There are no folders in this folder.
file_list_folder = folder
file_list_folders = folders
file_list_file = file
file_list_files = files
filedetails_select_file = Tap on a file to display additional information.
filedetails_path = Path:
filedetails_renamed_in_upload_msg = File was renamed to %1$s during upload
list_layout = List layout
common_remove_upload = Remove upload
common_retry_upload = Retry upload
common_save_exit = Save & exit
common_error = Error
common_loading = Loading…
common_unknown = unknown
change_password = Change password
delete_account = Remove account
create_account = Create account
uploader_upload_in_progress_ticker = Uploading…
uploader_upload_in_progress_content = %1$d%% Uploading %2$s
uploader_upload_succeeded_ticker = Upload succeeded
uploader_upload_failed_ssl_certificate_not_trusted = Server certificate is not trusted.
uploader_upload_available_offline_files = Uploading available offline files
uploader_upload_requested_from_wifi_files = Uploading requested from wifi files
uploads_view_upload_status_paused = Paused
uploads_view_upload_status_failed_retry = Upload will be retried shortly
uploads_view_upload_status_failed_firewall_error = Forbidden due to a firewall rule
uploads_view_later_waiting_to_upload = Waiting to upload
downloader_download_in_progress_ticker = Downloading…
downloader_download_in_progress_content = %1$d%% Downloading %2$s
downloader_download_forbidden_permissions = to download this file
sync_conflicts_in_favourites_ticker = Conflicts found
sync_conflicts_in_favourites_content = %1$d kept-in-sync files could not be sync\'ed
sync_fail_in_favourites_ticker = Kept-in-sync files failed
sync_fail_in_favourites_content = Contents of %1$d files could not be sync\'ed (%2$d conflicts)
pass_code_error_set = An error occurred while setting the passcode lock.
pass_code_error_remove = An error occurred while removing the passcode lock.
biometric_not_available = Biometric unlock is not available because system could not provide
media_err_no_account = No account provided
media_err_not_in_qsfera = File not in a valid account
auth_getting_authorization = Getting authorization…
auth_trying_to_login = Trying to log in…
auth_no_net_conn_title = No network connection
auth_nossl_plain_ok_title = Secure connection unavailable.
auth_unknown_error_title = Unknown error occurred!
auth_wrong_connection_title = Couldn\'t establish connection
auth_wtf_reenter_URL = Unexpected state; please enter the server address again
auth_connecting_auth_server = Connecting to authentication server …
auth_fail_get_user_name = Your server is not returning a correct user ID. Please contact an administrator.
auth_account_does_not_exist = Account does not exist in the device yet
available_offline_inherited_msg = A folder containing this file is available offline.
sync_file_fail_msg = Remote file could not be checked
filedisplay_unexpected_bad_get_content = Unexpected problem; please select the file from a different app
oauth_check_onoff = Login with oAuth2
oauth_login_connection = Connecting to OAuth2 server…
placeholder_filetype = PNG Image
proper_pics_folder_warning_camera_upload = Please make sure the folder selected is where the camera you are using saves the pictures taken. Otherwise, the feature will not be able to detect your pictures. Keep in mind that pictures will be uploaded in at least 15 minutes after taking them.
proper_videos_folder_warning_camera_upload = Please make sure the folder selected is where the camera you are using saves the videos recorded. Otherwise, the feature will not be able to detect your videos. Keep in mind that videos will be uploaded in at least 15 minutes after recording them.
preview_image_error_unknown_format = This image cannot be shown.
share_link_no_support_share_api = Sorry, sharing is not enabled on your server. Please contact your administrator.
share_link_password_title = Enter a password
share_link_empty_password = You must enter a password.
error_cant_bind_to_operations_service = Critical error: cannot perform operations
forbidden_permissions_rename = to rename this file
forbidden_permissions_delete = to delete this file
share_link_forbidden_permissions = to share this file
unshare_link_forbidden_permissions = to unshare this file
update_link_forbidden_permissions = to update this share
forbidden_permissions_create = to create the file
uploader_upload_forbidden = Upload not allowed
downloader_download_file_not_found = The file is no longer available on the server.
prefs_category_accounts = Accounts
used_quota_title_manage_accounts = Used:
actionbar_logger = Logs
log_send_history_button = Send history
log_send_no_mail_app = No app for sending logs found. Please install a mail app.
log_send_mail_subject = %1$s Android app logs
log_progress_dialog_text = Loading data…
file_list_empty_moving = Nothing in here. You can add a folder!
move_file_not_found = Unable to move. Please check whether the file exists.
forbidden_permissions_move = to move this file
copy_file_not_found = Unable to copy. Please check whether the file exists.
forbidden_permissions_copy = to copy this file
prefs_category_camera_upload = Camera uploads
sync_folder_failed_content = Synchronization of %1$s folder could not be completed
shared_subject_header = shared
with_you_subject_header = with you
auth_host_address = Server address
common_error_out_memory = Not enough memory
upload_copy_files = Copy file
upload_move_files = Move file
share_file = Share %1$s
share_incompatible_version = To share with other users, your server needs to be at least version 8.2
share_add_user_or_group = Add user or group
share_via_link_password_title = Secured
share_get_public_link_button = Get link
share_sharee_unavailable = Sorry, your server version does not allow sharing with users within clients.\nPlease contact your administrator
edit_share_unshare = Stop sharing
edit_share_done = done
auth_forbidden = Forbidden authentication
previewing_video_not_supported = Video previewing is not supported in your Android version. File download will start automatically.
share_via_link_empty_password = The password is empty.
clicks_to_be_developer = You are %1$d clicks away to enable developer menus
no_browser_installed_alert = No browser installed. Please install a browser in order to allow a secure login.
prefs_logs_summary = Logs may contain sensitive information. Sharing logs with others is sole user responsibility
synchronizing_account = Synchronizing account
scoped_storage_wizard_not_enough_space_warning = ⚠️ Your free space is currently limited on your device. Some files may not be migrated, please check that all downloaded files keep downloaded after the process has been completed.
folder_upload_some_content_or_sync_devices = Upload some content or sync with your devices!
release_notes_footer = Thank you for using %1$s.\n♥️
release_notes_4_5_0_title_feedback_in_previews = Feedback when (un)setting av. offline in all previews
release_notes_4_5_0_subtitle_feedback_in_previews = Added feedback when (un)setting av. offline in all previews and updated options menu depending on file status
release_notes_4_5_0_title_quota_improvements = Improvements in storage occupation display
release_notes_4_5_0_subtitle_quota_improvements = Storage occupation refreshed frequently in file and refresh operations and available in accounts manager view, besides of drawer menu
release_notes_4_5_0_title_light_users = Users Light
release_notes_4_5_0_subtitle_light_users = Users Light (users without personal space) are now supported in the app
release_notes_4_5_1_title_strange_behaviour_apps_provider = Fix for files creation via app providers
release_notes_4_5_1_subtitle_strange_behaviour_apps_provider = Files are now opened correctly when they are created using app providers
release_notes_title = КуСфера Client
release_notes_initial_release = Welcome to the initial release
ic_action_open_in_web = Open in web
server_accounts_warning_title = Accounts warning
server_accounts_warning_message = Please remove the account and log in again to get the spaces feature.
server_accounts_warning_checkbox_message = Don\'t show again
server_accounts_warning_button = Understood
content_description_get_public_link = Get public link
content_description_delete_public_link = Delete public link
content_description_get_edit_public_link = Edit public link
content_description_remove_account = Remove account
content_description_clean_account_storage = Clean account storage
content_description_edit_share = Edit share
content_description_delete_share = Delete share
create_shortcut_dialog_url_information = The URL must start with https:// or http:// so that it can be opened in the browser
feedback_dialog_get_in_contact_description = Discuss in our <a href=\"%1$s\"><b>GitHub repo</b></a>
release_notes_title_enhanced_bottom_nav_bar = Added text labels on bottom bar
release_notes_subtitle_bottom_nav_bar = Text labels were added and default active indicator is used to show which section is selected on the bottom bar
@@ -0,0 +1,694 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- GLOBAL -->
<string name="global_name">Имя</string>
<string name="global_date">Дата</string>
<string name="global_size">Размер</string>
<string name="actionbar_search">Поиск в текущей папке</string>
<string name="actionbar_search_space">Поиск пространств</string>
<string name="actionbar_upload">Загрузить</string>
<string name="actionbar_open_with">Открыть с помощью</string>
<string name="actionbar_open_with_read_only">Открыть с помощью (только чтение)</string>
<string name="actionbar_mkdir">Новый каталог</string>
<string name="actionbar_settings">Настройки</string>
<string name="actionbar_see_details">Подробно</string>
<string name="actionbar_send_file">Отправить</string>
<string name="actionbar_sort_title">Упорядочить по</string>
<string name="fab_new_file">Новый документ</string>
<string name="fab_new_shortcut">Новый ярлык</string>
<string name="drawer_item_only_available_offline">Доступные оффлайн</string>
<string name="drawer_close">Закрыть</string>
<string name="drawer_open">Открыть</string>
<string name="drawer_loading_quota">Загрузка квоты</string>
<string name="drawer_quota">Использовано %1$s из %2$s (%3$s %%)</string>
<string name="drawer_unavailable_free_storage">Использовано %1$s</string>
<string name="drawer_unavailable_used_storage">Нет данных о занятом объёме</string>
<string name="drawer_feedback">Обратная связь</string>
<string name="drawer_help">Помощь</string>
<string name="prefs_subsection_logging">Журналирование</string>
<string name="prefs_subsection_more">Больше</string>
<string name="prefs_subsection_security">Безопасность</string>
<string name="prefs_subsection_whatsnew">Что нового в этой версии?</string>
<string name="prefs_subsection_security_summary">Установка блокировок на доступ к приложению и разрешение на соприкосновение с другими видимыми окнами</string>
<string name="prefs_subsection_logging_summary">Отслеживание журналов, создаваемых при выполнении приложения</string>
<string name="prefs_subsection_more_summary">Информация о приложении, обратная связь, и прочее</string>
<string name="prefs_passcode">Блокировка кодом</string>
<string name="prefs_biometric">Биометрическая блокировка</string>
<string name="prefs_biometric_summary">Чтобы использовать эту настройку, включите блокировку по коду или графическому ключу</string>
<string name="prefs_lock_application">Блокировка приложения</string>
<string name="prefs_lock_application_entries_immediately">Немедленно</string>
<string name="prefs_lock_application_entries_1minute">Через 1 минуту</string>
<string name="prefs_lock_application_entries_5minutes">Через 5 минут</string>
<string name="prefs_lock_application_entries_30minutes">Через 30 минут</string>
<string name="prefs_lock_access_from_document_provider">Блокировать доступ к файлам системе</string>
<string name="prefs_lock_access_from_document_provider_summary">Блокировка доступа к файлам в аккаунтах ownCloud другим приложениям через стандартный файловый менеджер.</string>
<string name="prefs_touches_with_other_visible_windows">Разрешить работу под прозрачными окнами других приложений</string>
<string name="prefs_touches_with_other_visible_windows_summary">Разрешает взаимодействие, когда есть перекрытие другим видимым или прозрачным окном. Это нужно, если вы используете приложения для фильтрации света от экрана. Перекрывающее приложение сможет получать информацию о взаимодействии.</string>
<string name="confirmation_touches_with_other_windows_title">Вы уверены, что хотите включить эту функцию?</string>
<string name="confirmation_touches_with_other_windows_message">Используйте эту функцию на свой страх и риск. Вредоносное приложение может попытаться обманом заставить вас выполнить какие-либо действия, используя другие представления.</string>
<string name="prefs_subsection_picture_uploads">Автоматическая загрузка изображений</string>
<string name="prefs_subsection_picture_uploads_summary">Управляйте местоположением и поведением автоматически загружаемых фотографий</string>
<string name="prefs_subsection_video_uploads">Автоматическая загрузка видео</string>
<string name="prefs_subsection_video_uploads_summary">Настройка размещения и поведения видеороликов, загружаемых автоматически</string>
<string name="prefs_camera_picture_upload">Загрузка изображений</string>
<string name="prefs_camera_picture_upload_summary">Автоматически загружать изображения, сделанные камерой</string>
<string name="prefs_camera_picture_upload_path_title">Путь загрузки изображений</string>
<string name="prefs_camera_picture_upload_conditions_title">Условия для загрузки</string>
<string name="prefs_camera_picture_upload_conditions_summary">Автозагрузка будет запущена только при удовлетворении выбранных ниже условий</string>
<string name="prefs_camera_picture_upload_on_wifi">Загрузка изображений только через wifi</string>
<string name="prefs_camera_picture_upload_on_charging">Загрузка изображений только во время зарядки</string>
<string name="prefs_camera_video_upload">Загрузка видео</string>
<string name="prefs_camera_video_upload_summary">Автоматически загружать видео, записанные камерой</string>
<string name="prefs_camera_video_upload_path_title">Путь загрузки видео</string>
<string name="prefs_camera_video_upload_on_wifi">Загрузка видео только через wifi</string>
<string name="prefs_camera_video_upload_on_charging">Загрузка видео только во время зарядки</string>
<string name="prefs_subsection_advanced">Дополнительно</string>
<string name="prefs_subsection_notifications">Управление уведомлениями</string>
<string name="prefs_show_hidden_files">Показать скрытые файлы</string>
<string name="prefs_log_open_logs_list_view">Логи</string>
<string name="prefs_log_no_logs_list_view">Пусто</string>
<string name="prefs_log_empty_subtitle">Включите ведение журнала, и здесь будут отображаться файлы журнала.</string>
<string name="prefs_help">Помощь</string>
<string name="prefs_sync_calendar_contacts">Синхронизировать ваши контакты, календари и задачи</string>
<string name="prefs_sync_calendar_contacts_summary">Установить DAVx⁵</string>
<string name="prefs_access_document_provider">Блокировать доступ к файлам системе</string>
<string name="prefs_access_document_provider_summary">Предлагаемое приложение для просмотра файлов на вашем устройстве с помощью встроенного в Android проводника файлов</string>
<string name="prefs_recommend">Рекомендовать другу</string>
<string name="prefs_feedback">Обратная связь</string>
<string name="prefs_imprint">Штамп</string>
<string name="prefs_pattern">Графический ключ</string>
<string name="prefs_enable_logging">Включить журналирование</string>
<string name="prefs_enable_logging_summary">Активная запись лог файлов может влиять на расход ресурсов. Также они могут содержать конфиденциальную информацию. Обмен лог-файлами подлежит исключительно ответственности пользователя. Автоматическая загрузка лог-файлов на серверы ownCloud исключена.</string>
<string name="prefs_send_feedback">Отправить свой отзыв</string>
<string name="prefs_app_version">Версия приложения</string>
<string name="prefs_app_version_summary">%1$s %2$s версии %3$s (%4$s)</string>
<string name="pattern_enter_pattern">Пожалуйста, введите графический ключ</string>
<string name="pattern_configure_pattern">Введите графический ключ</string>
<string name="pattern_remove_pattern">Удалить графический ключ</string>
<string name="pattern_incorrect_pattern">Неверный графический ключ</string>
<string name="pattern_reenter_pattern">Введите графический ключ снова</string>
<string name="pattern_not_same_pattern">Ключи не совпадают</string>
<string name="pattern_already_set">Сначала отключите графический ключ. Графический ключ и код не могут быть включены одновременно.</string>
<string name="passcode_already_set">Сначала отключите код. Графический ключ и код не могут быть включены одновременно.</string>
<string name="pattern_no_longer_required">Графический ключ не будет больше запрашиваться</string>
<string name="pattern_configure_your_pattern_explanation">Графический ключ будет запрашиваться каждый раз при запуске приложения</string>
<string name="document_provider_locked">Заблокировано</string>
<string name="illegal_argument_exception_message">Допустимое ДЕЙСТВИЕ требуется для передачи в намерениях</string>
<!-- Bottom navigation bar -->
<string name="bottom_nav_files">Файлы</string>
<string name="bottom_nav_personal">Личный</string>
<string name="bottom_nav_uploads">Загрузки</string>
<string name="bottom_nav_offline">Оффлайн</string>
<string name="bottom_nav_links">Ссылки</string>
<string name="bottom_nav_shares">События обмена файлами</string>
<string name="recommend_subject">Попробуйте %1$s на вашем смартфоне!</string>
<string name="recommend_text">Хочу предложить вам использовать %1$s на смартфоне!\nЗагрузить можно здесь: %2$s\n\t</string>
<string name="error_reason">поскольку</string>
<string name="auth_failure_snackbar">Не удалось выполнить аутентификацию. Войдите в систему еще раз, чтобы восстановить доступ.</string>
<string name="server_not_supported">Версия вашего сервера ниже 10й, и поэтому не поддерживается. Пожалуйста, обновите ваш сервер до последней версии.</string>
<string name="auth_failure_snackbar_action">Измените учётную запись</string>
<string name="auth_oauth_failure">Токен доступа устарел или стал недопустимым. Введите имя и пароль повторно, чтобы восстановить доступ</string>
<string name="auth_oauth_failure_snackbar_action">Войти</string>
<string name="auth_check_server">Проверить сервер</string>
<string name="auth_username">Имя пользователя</string>
<string name="auth_password">Пароль</string>
<string name="auth_register">Незнакомы с %1$s?</string>
<string name="sync_string_files">Файлы</string>
<string name="setup_btn_login">Войти</string>
<string name="uploader_btn_upload_text">Загрузить</string>
<string name="uploader_top_message">Выбрать каталог для загрузки</string>
<string name="choose_upload_space">Выбрать каталог для загрузки</string>
<string name="uploader_wrn_no_account_title">Учётная запись не найдена</string>
<string name="uploader_wrn_no_account_text">На вашем устройстве нет аккаунтов %1$s. Пожалуйста выполните настройку аккаунта.</string>
<string name="uploader_wrn_no_account_setup_btn_text">Настройка</string>
<string name="uploader_wrn_no_account_quit_btn_text">Выход</string>
<string name="uploader_error_title_no_file_to_upload">Не выбран файл для загрузки</string>
<string name="uploader_error_message_no_file_to_upload">Принятые данные не включают в себя никаких файлов.</string>
<string name="uploader_error_title_file_cannot_be_uploaded">Файл не может быть загружен</string>
<string name="uploader_error_message_read_permission_not_granted">%1$s не позволено читать принятый файл</string>
<string name="uploader_error_message_source_file_not_found">Файл для загрузки не был найден в положенном месте. Пожалуйста проверьте, существует ли файл.</string>
<string name="uploader_error_message_source_file_not_copied">Произошла ошибка во время копирования этого файла во временную папку. Пожалуйста попробуйте послать заново.</string>
<string name="file_list_seconds_ago">несколько секунд назад</string>
<string name="file_list_empty_title_all_files">Здесь нет файлов</string>
<string name="spaces_list_empty_title">Нет пространств</string>
<string name="space_label">пространство:</string>
<string name="file_list_empty_title_available_offline">Нет файлов</string>
<string name="file_list_empty_title_shared_by_links">Нет ссылок</string>
<string name="file_list_empty_subtitle_all_files">Загрузите что-нибудь или синхронизируйте со своими устройствами!</string>
<string name="shares_list_empty_title">Нет пробелов</string>
<string name="shares_list_empty_subtitle">Вы не используете стороние ресурсами</string>
<string name="spaces_list_empty_subtitle">У вас нет доступа ни к одному пространству!</string>
<string name="file_list_empty_subtitle_available_offline">Здесь будут файлы и каталоги, отмеченные для оффлайн-режима</string>
<string name="file_list_empty_subtitle_shared_by_links">Здесь появятся файлы и каталоги, ссылкой на которые вы поделитесь</string>
<string name="file_list_no_app_for_file_type">Не найдено приложение для этого типа файла</string>
<string name="file_list_no_app_for_perform_action">Нет приложений, поддерживающих это действие</string>
<string name="local_file_list_search_with_no_matches">Нет соответствий для этого поиска</string>
<string name="upload_list_empty">Нет файлов</string>
<string name="upload_list_empty_subtitle">Здесь будут показаны загруженные файлы</string>
<string name="upload_to">Закачать в %1$s</string>
<string name="pick_document_type">Выберите тип документа для создания:</string>
<string name="upload_files_title">Файлы</string>
<string name="filedetails_size">Размер:</string>
<string name="filedetails_type">Тип:</string>
<string name="filedetails_created">Создан:</string>
<string name="filedetails_last_sync">Последняя синхронизация:</string>
<string name="filedetails_modified">Изменён:</string>
<string name="filedetails_download">Скачать</string>
<string name="filedetails_file_image_content_description">Файл изображения</string>
<string name="filedetails_cancel_btn_content_description">Кнопка отмены загрузки</string>
<string name="filedetails_sync_file">Синхронизация</string>
<string name="action_share">Общий доступ</string>
<string name="login_account_preparing">Подготовка аккаунта к первому запуску</string>
<string name="common_yes">Да</string>
<string name="common_no">Нет</string>
<string name="common_cancel_sync">Отменить синхронизацию</string>
<string name="common_back">Назад</string>
<string name="common_error_unknown">неизвестная ошибка</string>
<string name="common_pending">Ожидается</string>
<string name="common_important">Важный</string>
<string name="upload_chooser_title">Загрузить из</string>
<string name="uploader_info_dirname">Имя каталога</string>
<string name="uploader_upload_succeeded_content_single">%1$s загружено</string>
<string name="uploader_upload_failed_ticker">Ошибка загрузки</string>
<string name="uploader_upload_failed_content_single">Загрузка %1$s не может быть завершена</string>
<string name="uploader_upload_failed_credentials_error">Загрузка не удалась, вам необходимо войти повторно.</string>
<string name="uploader_upload_text_dialog_title">Укажите имя для нового файла</string>
<string name="uploader_upload_text_dialog_filename_error_empty">Имя файла не должно быть пустым</string>
<string name="uploader_upload_text_dialog_filename_error_length_max">Имя файла должно быть не длиннее %d символов</string>
<string name="uploader_upload_text_filename_hint">Полное имя</string>
<string name="uploader_upload_camera_upload_files">Загрузка файлов, загруженных из камеры</string>
<string name="uploader_upload_picture_upload_files">Будет загружено %d новых изображений</string>
<string name="uploader_upload_video_upload_files">Будет загружено %d новых видео</string>
<string name="uploader_upload_camera_upload_source_path_error">Загрузка из камеры не удалась</string>
<string name="uploader_upload_picture_upload_error">Исходный путь для загрузки изображений больше не является допустимым</string>
<string name="uploader_upload_video_upload_error">Исходный путь для загрузки видео больше не является допустимым</string>
<string name="ssl_certificate_not_trusted">Нет доверия сертификату сервера</string>
<string name="uploads_view_title">Загрузки</string>
<string name="uploads_view_group_current_uploads">Текущие</string>
<string name="uploads_view_group_failed_uploads">Не удалось (тапните для повторения)</string>
<string name="uploads_view_group_finished_uploads">Загружено</string>
<string name="uploads_view_group_queued_uploads">В очереди</string>
<string name="uploads_view_group_file_count">%d файлов</string>
<string name="uploads_view_group_file_count_single">%d файл</string>
<string name="uploads_view_upload_status_succeeded">Завершённые</string>
<string name="uploads_view_upload_status_cancelled">Отменён</string>
<string name="uploads_view_upload_status_failed_connection_error">Ошибка соединения</string>
<string name="uploads_view_upload_status_failed_credentials_error">Ошибка учетных данных</string>
<string name="uploads_view_upload_status_failed_folder_error">Ошибка каталога</string>
<string name="uploads_view_upload_status_failed_file_error">Ошибка файла</string>
<string name="uploads_view_upload_status_failed_localfile_error">Локальный файл не найден</string>
<string name="uploads_view_upload_status_failed_permission_error">Ошибка доступа</string>
<string name="uploads_view_upload_status_conflict">Конфликт</string>
<string name="uploads_view_upload_status_service_interrupted">Работа приложения была прекращена</string>
<string name="uploads_view_upload_status_unknown_fail">Неизвестная ошибка</string>
<string name="uploads_view_upload_status_waiting_for_wifi">Ожидание соединения по wifi</string>
<string name="uploads_view_upload_status_queued">В очереди</string>
<string name="uploads_view_unsupported_media_type">Неподдерживаемый тип медиаданных</string>
<string name="uploader_upload_enqueued_ticker">Загрузка %1$s в очереди</string>
<string name="uploader_upload_canceled_ticker">Загрузка отменена</string>
<string name="downloader_download_canceled_ticker">Скачивание отменено</string>
<string name="downloader_download_enqueued_ticker">Скачивание %1$s в очереди</string>
<string name="downloader_download_succeeded_ticker">Скачивание завершено</string>
<string name="downloader_download_succeeded_content">%1$s скачан</string>
<string name="downloader_download_failed_ticker">Скачивание не удалось</string>
<string name="downloader_preview_failed_ticker">Не удалось отобразить файл</string>
<string name="downloader_download_failed_content">Скачивание %1$s не может быть завершено</string>
<string name="downloader_not_downloaded_yet">Ещё не скачано</string>
<string name="downloader_download_failed_credentials_error">Скачивание не удалось, вам необходимо войти повторно</string>
<string name="common_choose_account">Выберите учётную запись</string>
<string name="sync_fail_ticker">Синхронизация не удалась</string>
<string name="sync_fail_ticker_unauthorized">Синхронизация не удалась, вам нужно войти повторно</string>
<string name="sync_fail_content">Синхронизация %1$s не может быть завершена</string>
<string name="sync_fail_content_unauthorized">Неверный пароль для %1$s</string>
<string name="sync_current_folder_was_removed">Целевой каталог 1%1$s уже не существует</string>
<string name="pass_code_enter_pass_code">Пожалуйста, укажите ваш код</string>
<string name="pass_code_configure_your_pass_code">Укажите ваш код</string>
<string name="pass_code_configure_your_pass_code_migration">Введите новый %1$d-значный код</string>
<string name="pass_code_configure_your_pass_code_explanation">Код будет запрашиваться каждый раз при запуске приложения</string>
<string name="pass_code_reenter_your_pass_code">Пожалуйста, укажите ваш код еще раз</string>
<string name="pass_code_remove_your_pass_code">Убрать код</string>
<string name="pass_code_mismatch">Коды не совпадают</string>
<string name="pass_code_wrong">Некоррентный код</string>
<string name="numberkeyboard_backspace_button_content_description">Кнопка Backspace</string>
<string name="numberkeyboard_biometric_button_content_description">Биометрическая кнопка</string>
<string name="numberkeyboard_zero">0</string>
<string name="numberkeyboard_one">1</string>
<string name="numberkeyboard_two">2</string>
<string name="numberkeyboard_three">3</string>
<string name="numberkeyboard_four">4</string>
<string name="numberkeyboard_five">5</string>
<string name="numberkeyboard_six">6</string>
<string name="numberkeyboard_seven">7</string>
<string name="numberkeyboard_eight">8</string>
<string name="numberkeyboard_nine">9</string>
<string name="lock_time_try_again">Попробуйте ещё раз через %1$s</string>
<string name="biometric_not_enrolled">Чтобы пользоваться этой возможностью, зарегистрируйте хотя-бы одну биометрию</string>
<string name="biometric_prompt_title">Биометрический вход</string>
<string name="biometric_prompt_subtitle">Вход с использованием ваших индивидуальных биометрических данных</string>
<string name="biometric_dialog_title">Хотите дополнительно активировать биометрическую защиту?</string>
<string name="media_notif_ticker">%1$s аудиоплеер</string>
<string name="media_state_playing">%1$s (проигрывается)</string>
<string name="media_state_loading">%1$s (загружается)</string>
<string name="media_event_done">%1$s воспроизведение завершено</string>
<string name="media_err_nothing_to_play">Медиафайлы не найдены</string>
<string name="media_err_not_in_owncloud">Файл в неверной учётной записи</string>
<string name="media_err_unsupported">Неподдерживаемый кодек</string>
<string name="media_err_io">Медиафайл не может быть прочитан</string>
<string name="media_err_malformed">Медиафайл некорректно закодирован</string>
<string name="media_err_timeout">Истекло время попытки воспроизведения</string>
<string name="media_err_invalid_progressive_playback">Невозможно организовать потоковую передачу медиафайла</string>
<string name="media_err_unknown">Медиафайл не может быть проигран стандартным плеером</string>
<string name="media_err_security_ex">Ошибка безопасности при воспроизведении %1$s</string>
<string name="media_err_io_ex">Ошибка ввода при воспроизведении %1$s</string>
<string name="media_err_unexpected">Неожиданная ошибка при воспроизведении %1$s</string>
<string name="media_rewind_description">Перемотка назад</string>
<string name="media_play_pause_description">Воспроизведение или пауза</string>
<string name="media_forward_description">Перемотка вперед</string>
<string name="upload_from_camera_title">Изображение из камеры</string>
<string name="auth_connection_established">Соединение установлено</string>
<string name="auth_testing_connection">Проверка соединения</string>
<string name="auth_not_configured_title">Конфигурация сервера задана неверно</string>
<string name="auth_account_not_new">Учётная запись такого пользователя и сервера уже существует на устройстве</string>
<string name="auth_account_not_the_same">Указанный пользователь не соответствует этой учётной записи</string>
<string name="auth_incorrect_path_title">Сервер не найден</string>
<string name="auth_incorrect_address_title">Неправильный формат адреса сервера</string>
<string name="auth_ssl_general_error_title">Ошибка инициализации SSL</string>
<string name="auth_bad_oc_version_title">Неизвестная версия сервера</string>
<string name="auth_secure_connection">Защищённое соединение установлено</string>
<string name="auth_unauthorized">Неверное имя пользователя или пароль</string>
<string name="auth_oauth_error">Ошибка авторизации</string>
<string name="auth_oauth_error_access_denied">Сервер авторизации отказал в доступе</string>
<string name="auth_expired_oauth_token_toast">Время авторизации истекло. Пожалуйста, авторизуйтесь снова</string>
<string name="auth_expired_basic_auth_toast">Пожалуйста укажите текущий пароль</string>
<string name="auth_unsupported_auth_method">Сервер не поддерживает выбранный метод аутентификации</string>
<string name="auth_can_not_auth_against_server">Не удается авторизоваться на этом сервере</string>
<string name="set_available_offline">Сделать доступным автономно</string>
<string name="unset_available_offline">Убрать доступность автономно</string>
<string name="confirmation_set_available_offline">Файл был успешно установлен для работы в оффлайн-режиме</string>
<string name="confirmation_unset_available_offline">Файл был успешно снят с оффлайн-доступа</string>
<string name="common_rename">Переименовать</string>
<string name="common_remove">Удалить</string>
<string name="confirmation_remove_file_alert">Вы действительно хотите удалить %1$s?</string>
<string name="confirmation_remove_folder_alert">Вы действительно хотите удалить %1$s и его содержимое?</string>
<string name="confirmation_remove_local">Только локально</string>
<string name="confirmation_remove_account_alert">Вы действительно хотите удалить аккаунт %1$s?</string>
<string name="confirmation_remove_account_alert_camera_uploads">К этой учётной записи привязаны закачки из камеры; удаление записи отключит закачки из камеры. Вы действительно хотите удалить учётную запись %1$s?</string>
<string name="confirmation_remove_public_share_title">Удалить ссылку</string>
<string name="confirmation_remove_private_share_title">Удалить общий доступ</string>
<string name="confirmation_remove_share_message">Вы действительно хотите удалить общий доступ %1$s?</string>
<string name="remove_success_msg">Удаление завершено</string>
<string name="remove_fail_msg">Ошибка удаления</string>
<string name="rename_dialog_title">Введите новое имя</string>
<string name="rename_local_fail_msg">Локальная копия не может быть переименована; попробуйте другое имя</string>
<string name="rename_server_fail_msg">Переименование не может быть завершено</string>
<string name="file_already_exists">файл уже существует</string>
<string name="folder_already_exists">файл уже существует</string>
<string name="file_already_exists_description">Элемент с тем же именем 1%1$s уже существует</string>
<string name="folder_already_exists_description">Элемент с тем же именем 1%1$s уже существует</string>
<string name="sync_file_nothing_to_do_msg">Содержимое файла уже синхронизировано</string>
<string name="sync_file_not_found_msg">Файл не найден</string>
<string name="new_remote_version_found_msg">Найдена новая версия на сервере. Загрузка...</string>
<string name="download_enqueued_msg">Скачивание в очереди</string>
<string name="upload_enqueued_msg">Загрузка в очереди</string>
<string name="create_dir_fail_msg">Не удалось создать каталог</string>
<string name="create_file_fail_msg">Не удалось создать каталог</string>
<string name="filename_forbidden_characters">Запрещённые символы: / \\</string>
<string name="filename_forbidden_characters_from_server">Имя файла содержит по крайней мере один некорректный символ</string>
<string name="filename_empty">Имя файла не может быть пустым</string>
<string name="filename_too_long">Неверная длина имени файла</string>
<string name="wait_a_moment">Подождите немного</string>
<string name="wait_checking_credentials">Проверка сохранённых учётных данных</string>
<string name="filedisplay_no_file_selected">Файлы не выбраны</string>
<string name="activity_chooser_title">Отправка ссылки пользователю</string>
<string name="wait_for_tmp_copy_from_private_storage">Копирование файла из частного хранилища</string>
<string name="ssl_connection_not_secure">Соединение небезопасно, и http-трафик запрещен.</string>
<string name="ssl_validator_header">Не удалось проверить подлинность сервера</string>
<string name="ssl_validator_reason_cert_not_trusted">- Сертификат сервера не является доверенным</string>
<string name="ssl_validator_reason_cert_expired">- Срок действия сертификата сервера истёк</string>
<string name="ssl_validator_reason_cert_not_yet_valid">- Сертификат сервера слишком новый</string>
<string name="ssl_validator_reason_hostname_not_verified">- URL не совпадает с именем сервера в сертификате</string>
<string name="ssl_validator_question">Доверять этому сертификату в любом случае?</string>
<string name="ssl_validator_not_saved">Сертификат не может быть сохранён</string>
<string name="ssl_validator_btn_details_see">Подробно</string>
<string name="ssl_validator_btn_details_hide">Скрыть</string>
<string name="ssl_validator_label_subject">Кому выдан:</string>
<string name="ssl_validator_label_issuer">Кем выдан:</string>
<string name="ssl_validator_label_CN">Имя:</string>
<string name="ssl_validator_label_O">Организация:</string>
<string name="ssl_validator_label_OU">Подразделение:</string>
<string name="ssl_validator_label_C">Страна:</string>
<string name="ssl_validator_label_ST">Штат:</string>
<string name="ssl_validator_label_L">Местонахождение:</string>
<string name="ssl_validator_label_validity">Срок действия:</string>
<string name="ssl_validator_label_validity_from">С:</string>
<string name="ssl_validator_label_validity_to">По:</string>
<string name="ssl_validator_label_signature">Подпись:</string>
<string name="ssl_validator_label_signature_algorithm">Алгоритм:</string>
<string name="digest_algorithm_not_available">Этот хэш алгоритм не доступен на этом телефоне.</string>
<string name="ssl_validator_label_certificate_fingerprint">Отпечаток пальца:</string>
<string name="certificate_load_problem">Проблема при загрузке сертификата.</string>
<string name="ssl_validator_null_cert">Сертификат не может быть показан.</string>
<string name="ssl_validator_no_info_about_error">- Нет информации об ошибке</string>
<string name="placeholder_sentence">Это заполнитель</string>
<string name="placeholder_filename">placeholder.txt</string>
<string name="placeholder_filesize">389 КБ</string>
<string name="placeholder_timestamp">2012/05/18 12:23 PM</string>
<string name="placeholder_media_time">12:23:45</string>
<string name="confirmation_disable_camera_uploads_title">Подтвердите</string>
<string name="confirmation_disable_pictures_upload_message">Вы уверены, что хотите отключить эту возможность? Последующие изображения не будут загружены</string>
<string name="confirmation_disable_videos_upload_message">Вы уверены, что хотите отключить эту возможность? Последующие видеозаписи не будут загружены</string>
<string name="conflict_title">Файл в конфликте</string>
<string name="conflict_description">Конфликт в файле %1$s, нажмите чтобы разрешить его</string>
<string name="conflict_message">Какие файлы Вы хотите сохранить? При выборе обеих версий, к названию локального файла будет добавлена цифра</string>
<string name="conflict_keep_both">Сохранить оба</string>
<string name="conflict_use_local_version">локальная версия</string>
<string name="conflict_use_server_version">удаленная версия</string>
<string name="error_conflict">Произошла ошибка в папке назначения</string>
<string name="conflict_replace">отмена</string>
<string name="preview_image_description">Предпросмотр</string>
<string name="error__upload__local_file_not_copied">%1$s невозможно скопировать в локальный каталог %2$s </string>
<string name="get_shares_error">Не удалось запросить с сервера список ресурсов общего доступа</string>
<string name="get_sharees_error">Не удалось запросить с сервера список пользователей и групп</string>
<string name="get_capabilities_error">Не удалось запросить с сервера список возможностей</string>
<string name="share_link_file_error">Не удалось предоставить доступ к этому файлу или каталогу</string>
<string name="unshare_link_file_error">Не удалось убрать общий доступ с этого файла или каталога</string>
<string name="update_link_file_error">Не удалось обновить этот файл или каталог</string>
<string name="activity_chooser_send_file_title">Отправить</string>
<string name="copy_link">Копировать ссылку</string>
<string name="clipboard_text_copied">Скопировано в буфер обмена</string>
<string name="clipboard_no_text_to_copy">Не получен текст для копирования в буфер обмена</string>
<string name="clipboard_uxexpected_error">Неожиданная ошибка во время копирования в буфер обмена</string>
<string name="clipboard_label">Текст скопирован из %1$s</string>
<string name="network_error_socket_exception">При подключении к серверу возникла ошибка</string>
<string name="network_error_socket_timeout_exception">Ответ сервера занимает слишком много времени</string>
<string name="network_error_connect_timeout_exception">Слишком много времени ушло на подключение к серверу</string>
<string name="network_host_not_available">Сервер не доступен по сети</string>
<string name="network_error_message">Произошла ошибка в сети</string>
<string name="resource_locked_error_message">Ресурс не заблокирован</string>
<string name="forbidden_permissions">У вас нет прав %s</string>
<string name="uploader_upload_forbidden_permissions">для загрузки в этот каталог</string>
<string name="prefs_add_account">Добавить учетную запись</string>
<string name="drawer_manage_accounts">Управление аккаунтами</string>
<string name="manage_accounts_quota">%1$s из %2$s</string>
<string name="auth_redirect_non_secure_connection_title">Безопасное соединение перенаправлено через небезопасный маршрут.</string>
<string name="actionbar_select_all">Выделить все</string>
<string name="actionbar_select_inverse">Инвертировать выделение</string>
<string name="actionbar_move">Переместить</string>
<string name="folder_picker_choose_button_text">Выбрать</string>
<string name="folder_picker_move_here_button_text">Поместить сюда</string>
<string name="folder_picker_copy_here_button_text">Копировать сюда</string>
<string name="folder_picker_no_permissions_message_text">У Вас нет полномочий для добавления контактов</string>
<string name="move_file_invalid_into_descendent">Невозможно переместить каталог в его подкаталог</string>
<string name="move_file_invalid_overwrite">Файл уже существует в каталоге назначения</string>
<string name="move_file_error">Произошла ошибка при попытке перемещения этого файла или каталога</string>
<string name="copy_file_invalid_into_descendent">Нельзя произвести копирование папки в унаследованную папку</string>
<string name="copy_file_invalid_overwrite">Файл уже существует в каталоге назначения</string>
<string name="copy_file_error">Произошла ошибка во время копирования этого файла или папки</string>
<string name="subject_user_shared_with_you">%1$s предоставил вам доступ к \"%2$s\"</string>
<string name="subject_shared_with_you">Вам предоставлен доступ к \"%1$s\"</string>
<string name="auth_refresh_button">Обновить соединение</string>
<string name="username">Имя пользователя</string>
<string name="file_list__footer__folder">1 каталог</string>
<string name="file_list__footer__folders">%1$d каталогов</string>
<string name="file_list__footer__file">1 файл</string>
<string name="file_list__footer__file_and_folder">1 файл, 1 папка</string>
<string name="file_list__footer__file_and_folders">1 файл, %1$d каталогов</string>
<string name="file_list__footer__files">%1$d файлов</string>
<string name="file_list__footer__files_and_folder">%1$d файлов, 1 каталог</string>
<string name="file_list__footer__files_and_folders">%1$d файлов, %2$d каталогов</string>
<string name="prefs_picture_upload_account">Учётная запись для закачки изображений</string>
<string name="prefs_video_upload_account">Учётная запись для закачки видео</string>
<string name="prefs_camera_upload_source_path_title">Каталог камеры (%1$s)</string>
<string name="prefs_camera_upload_source_path_title_required">обязательно</string>
<string name="prefs_camera_upload_behaviour_dialog_title">Исходный файл будет</string>
<string name="prefs_camera_upload_behaviour_title">Исходный файл будет</string>
<string name="prefs_camera_upload_last_sync_title">Последняя синхронизация</string>
<string name="notifications_permission_denied">Вы можете обновить свои установки в Настройки</string>
<string name="pref_behaviour_entries_keep_file">остался в исходной папке</string>
<string name="pref_behaviour_entries_remove_original_file">удалено из исходной папки</string>
<string name="share_dialog_title">Общий доступ</string>
<string name="share_with_user_section_title">Пользователи и группы</string>
<string name="share_no_users">Нет данных используемых совместно с другими пользователями</string>
<string name="share_via_link_section_title">Общедоступные ссылки</string>
<string name="share_via_link_create_title">Создать доступ по ссылке</string>
<string name="share_via_link_edit_title">Изменить доступ по ссылке</string>
<string name="share_via_link_name_label">Имя ссылки</string>
<string name="share_cancel_public_link_button">Отмена</string>
<string name="share_confirm_public_link_button">Сохранить</string>
<string name="share_via_link_expiration_date_label">Срок действия</string>
<string name="share_via_link_password_label">Пароль</string>
<string name="share_via_link_generate_password_button">Общие</string>
<string name="share_via_link_copy_password_button">Копировать</string>
<string name="share_via_link_edit_permission_read_only_label">Скачивание / Просмотр</string>
<string name="share_via_link_edit_permission_read_and_write_label">Скачивание / Просмотр / Закачка</string>
<string name="share_via_link_edit_permission_upload_only_label">Только закачка (перетащить файл сюда)</string>
<string name="share_with_title">Поделиться с</string>
<string name="share_with_edit_title">Поделиться с %1$s</string>
<string name="share_search">Найти</string>
<string name="search_users_and_groups_hint">Поиск пользователей и групп</string>
<string name="share_group_clarification">%1$s (группа)</string>
<string name="share_remote_clarification">%1$s (в общем доступе)</string>
<string name="share_known_remote_clarification">%1$s ( в %2$s )</string>
<string name="share_privilege_can_share">можно поделиться</string>
<string name="share_privilege_can_edit">можно редактировать</string>
<string name="share_privilege_can_edit_create">создать</string>
<string name="share_privilege_can_edit_change">изменить</string>
<string name="share_privilege_can_edit_delete">удалить</string>
<string name="action_upload_retry">Повторить</string>
<string name="action_upload_clear">Очистить</string>
<string name="local_file_not_found_toast">Файл не был найден в локальной файловой системе</string>
<string name="confirmation_remove_files_alert">Вы действительно хотите удалить выбранные %s элементы?</string>
<string name="confirmation_remove_folders_alert">Вы действительно хотите удалить выбранные элементы вместе с их содержимым?</string>
<string name="failed_upload_quota_exceeded_text">Недостаточно места в учётной записи</string>
<string name="actionbar_privacy_policy">Политика конфиденциальности</string>
<string name="prefs_privacy_policy">Политика конфиденциальности</string>
<string name="privacy_policy_error">Произошла ошибка:</string>
<string name="service_unavailable">Сервер недоступен.</string>
<string name="streaming_certificate_error">Не удалось запустить потоковую передачу, так как сертификат вашего сервера не является доверенным. Скачивание запустится автоматически`</string>
<string name="streaming_unrecognized_input">Этот формат видео не поддерживается</string>
<string name="streaming_file_not_found_error">Этот видеофайл больше не доступен на сервере</string>
<string name="streaming_crossed_redirection">Потоковая передача не разрешена через перекрёстные перенаправления</string>
<string name="previewing_video_common_error">Это видео невозможно воспроизвести.</string>
<string name="streaming_position_not_available">"Позиция недоступна"</string>
<string name="error_no_network_connection">Устройство не подключено к сети</string>
<string name="share_no_public_links">Ещё не создано ни одной общедоступной ссылки.</string>
<string name="share_no_link_in_this_share">У этого общего ресурса нет ссылки.</string>
<string name="share_warning_about_forwarding_public_links">Каждый, у кого есть эта ссылка, имеет доступ к файлу/каталогу.</string>
<string name="share_via_link_default_password">*****</string>
<string name="share_via_link_password_enforced_label">Пароль *</string>
<string name="share_via_link_expiration_date_enforced_label">Срок действия</string>
<string name="share_via_link_expiration_date_explanation_label">Общедоступная ссылка устареет не позднее, чем через %1$d дней после создания.</string>
<string name="share_via_link_default_name_template">Ссылка %1$s</string>
<string name="private_link_info">Личная ссылка: её могут использовать только те, у кого есть доступ к файлу или каталогу. Используйте её в качестве постоянной ссылки для себя или чтобы указать другим на файлы в общем доступе</string>
<string name="file_private_link_error">Личная ссылка для этого файла недоступна</string>
<string name="download_notification_channel_name">Скачивания</string>
<string name="download_notification_channel_description">Просмотр имени скачиваемого файла и процесса</string>
<string name="upload_notification_channel_name">Загрузки</string>
<string name="upload_notification_channel_description">Просмотр имени закачиваемого файла и процесса</string>
<string name="media_service_notification_channel_name">Проигрыватель музыки</string>
<string name="media_service_notification_channel_description">Просмотр проигрывателя музыки</string>
<string name="file_sync_notification_channel_name">Синхронизация файлов</string>
<string name="file_sync_notification_channel_description">Просмотр результатов синхронизации файлов</string>
<string name="file_sync_conflict_notification_channel_name">Конфликты файлов</string>
<string name="file_sync_conflict_notification_channel_description">Смотрите конфликты файлов, когда они происходят</string>
<string name="rate_dialog_title">Оцените приложение %1$s!</string>
<string name="rate_dialog_description">Если вам нравится пользоваться этим приложением, не уделите ли вы минутку, чтобы оценить его? Ваш отзыв очень важен для нас.</string>
<string name="button_no_thanks_name">НЕТ, СПАСИБО</string>
<string name="button_later_name">ПОЗЖЕ</string>
<string name="button_rate_now_name">ОЦЕНИТЬ СЕЙЧАС</string>
<plurals name="items_selected_count">
<item quantity="one">Выбран %d</item>
<item quantity="few">Выбрано %d</item>
<item quantity="many">Выбрано %d</item>
<item quantity="other">Выбрано %d</item>
</plurals>
<!-- Welcome to oC intro features -->
<string name="welcome_feature_skip_button">Пропустить</string>
<string name="welcome_feature_1_title">Управляйте всеми вашими синхронизированными файлами</string>
<string name="welcome_feature_1_text">Вы можете копировать, перемещать, удалять</string>
<string name="welcome_feature_2_title">Делитесь файлами и папками</string>
<string name="welcome_feature_2_text">Вы можете делиться лично или со всеми</string>
<string name="welcome_feature_3_title">Много учётных записей</string>
<string name="welcome_feature_3_text">Подключайтесь ко всем вашим серверам ownCloud</string>
<string name="welcome_feature_4_title">Загрузки камеры</string>
<string name="welcome_feature_4_text">Ваши снимки и видео загружаются автоматически</string>
<string name="welcome_feature_5_title"> Потоковое видео</string>
<string name="welcome_feature_5_text">Проигрывайте ваши ролики, не скачивая их</string>
<string name="common_not_found">не найдено</string>
<string name="supports_oauth2_error">Невозможно было узнать, поддерживается ли OAuth2</string>
<string name="get_base_url_error">Узнать базу сервера, было невозможно</string>
<string name="prefs_http_logs">Записывать в лог-файл HTTP запросы и ответы</string>
<string name="spaces_sync_failed">Пространства не могут быть обновлены</string>
<!-- Scoped storage migration wizard -->
<string name="scoped_storage_wizard_explanation">Найдено %1$s данных на вашем внешнем хранилище. Он будет перемещен в безопасное хранилище на вашем устройстве. Оставшиеся файлы будут удалены из вашего внешнего хранилища после переноса, чтобы избежать дублирования и уязвимости.</string>
<string name="scoped_storage_wizard_intro">Ваше приложение было обновлено. Это обновление приложения переносит ваши файлы в более безопасное место на вашем устройстве. Эта одноразовая миграция данных обязательна и может занять до минуты или больше.</string>
<string name="scoped_storage_wizard_intro_button">Давайте начнём</string>
<string name="scoped_storage_wizard_migrate_and_clean_button">Перенести сейчас</string>
<string name="scoped_storage_wizard_migration_completed">Перенос завершен успешно. Ваши файлы теперь в большей безопасности, чем когда-либо прежде.</string>
<string name="scoped_storage_wizard_migration_completed_access_files_button">Доступ к вашим файлам</string>
<string name="scoped_storage_wizard_migration_in_progress">Перенос ваших файлов. Пожалуйста, не выключайте устройство.</string>
<string name="scoped_storage_wizard_migration_in_progress_button">Работает… Пожалуйста, подождите</string>
<string name="scoped_storage_wizard_title">Больше безопасности для ваших файлов</string>
<!-- Security Enforced -->
<string name="security_enforced_title">Пожалуйста, выберите вариант блокировки приложения:</string>
<string name="security_enforced_first_option">Блокировка кодом</string>
<string name="security_enforced_second_option">Шаблон заблокирован</string>
<!-- Webfinger -->
<string name="global_proceed">Продолжить</string>
<string name="error_webfinger_username_empty">Имя пользователя пусто</string>
<!-- Release Notes -->
<string name="release_notes_header">Новое в %1$s</string>
<string name="release_notes_proceed">Продолжить</string>
<string name="release_notes_icon">Иконка к примечанию релиза</string>
<string name="release_notes_bugfixes_title">Незначительные исправления ошибок</string>
<string name="release_notes_bugfixes_subtitle">Некоторые ошибки были исправлены для улучшения работы приложения</string>
<!-- Open in web -->
<string name="ic_action_open_with_web">Открыть в %1$s (веб)</string>
<string name="open_in_web_error_generic">Не удалось открыть в сети</string>
<string name="open_in_web_error_not_supported">нет приложений, которые поддерживают этот тип файла</string>
<string name="open_in_web_error_too_early">Запрашиваемый файл в данный момент недоступен, пожалуйста, попробуйте позже </string>
<!-- oCIS accounts warning -->
<string name="ocis_accounts_warning_title">Предупреждение о бесконечной шкале счетов</string>
<string name="ocis_accounts_warning_message">Пожалуйста, удалите учетную запись и войдите снова, чтобы получить возможность использования пространств.</string>
<string name="ocis_accounts_warning_checkbox_message">Не показывать снова</string>
<string name="ocis_accounts_warning_button">понял</string>
<string name="tab_label_ascii">Текст</string>
<string name="apply_to_all_conflicts">Применить ко всем %1$s конфликтам </string>
<string name="log_file_downloaded">загружен файл журнала</string>
<string name="go_to_download_folder">Скачивания</string>
<string name="log_file_downloaded_description">Файл журнала %1$s был правильно загружен в папку \"Загрузки\".</string>
<!-- DeepLinks -->
<string name="invalid_deep_link_format">Недопустимый формат ссылки</string>
<string name="deep_link_user_no_access">Пользователь не имеет доступа к файлу</string>
<string name="deep_link_loading">Открытие файлов по ссылке</string>
<string name="fab_refresh_text">Обновить</string>
<string name="fab_refresh_sync_in_progress">Синхронизация уже выполняется</string>
<string name="password_policy_min_characters">иметь длину как минимум 1%1$d символов </string>
<string name="password_policy_max_characters">иметь длину как минимум 1%1$d символов </string>
<string name="password_policy_min_digits">Как минимум %1$d цифры</string>
<string name="password_policy_lowercase_characters">Как минимум %1$d специальных символов</string>
<string name="password_policy_uppercase_characters">Не менее %1$d символов верхнего регистра</string>
<string name="password_policy_intro">Пожалуйста, введите пароль, соответствующий следующим критериям: \n</string>
<string name="password_policy_min_special_character">Не менее %1$d специальных символов: \"%2$s\"</string>
<string name="password_policy_error_password_banned">К сожалению, ваш пароль часто используется. Пожалуйста, выберите более сложный для угадывания пароль для вашей безопасности.</string>
<string name="prefs_delete_local_files_entries_never">Никогда</string>
<string name="prefs_delete_local_files_entries_1hour">1 час</string>
<string name="prefs_delete_local_files_entries_12hours">12 часов</string>
<string name="prefs_delete_local_files_entries_1day">1 день</string>
<string name="prefs_delete_local_files_entries_30days">30 дней</string>
<string name="prefs_delete_local_files">Удалить локальные копии</string>
<string name="prefs_delete_local_files_summary">Удалите автоматически загруженные файлы, которые недоступны в автономном режиме, если время, прошедшее с момента их последнего использования, превышает выбранное время.\nВыбранное время: %1$s</string>
<string name="insecure_http_url_title_dialog">Небезопасный HTTP-URL</string>
<string name="insecure_http_url_message_dialog">Указанный вами URL-адрес использует протокол HTTP, а не зашифрованный протокол HTTPS. Если вы продолжите, ваше общение не будет зашифровано.</string>
<string name="insecure_http_url_continue_button">Продолжить</string>
<string name="clean_data_account_message">Все загруженные файлы, которые недоступны в оффлайн-режиме, будут удалены из аккаунта %1$s</string>
<string name="clean_data_account_title">удалить локальное хранилище</string>
<string name="clean_data_account_button_yes">Очистить данные</string>
<string name="content_description_menu">Меню</string>
<string name="content_description_manage_accounts">Управление аккаунтами</string>
<string name="content_description_search">Найти</string>
<string name="content_description_type_view">Тип просмотра</string>
<string name="content_description_image_previewed">Предварительный просмотр изображений</string>
<string name="content_description_add_public_link">Добавить публичную ссылку</string>
<string name="content_description_get_private_link">Получить приватную ссылку</string>
<string name="content_description_add_new_content">Добавить новый контент</string>
<string name="content_description_add_new_content_expanded">Добавить новый контент, развернутый</string>
<string name="content_description_logo">Лого</string>
<string name="content_description_add_share">Добавить долю</string>
<string name="content_description_file_operations">%1$s операции</string>
<string name="content_description_sort_by_name_ascending">Сортировать %1$s по возрастанию</string>
<string name="content_description_sort_by_name_descending">Сортировать %1$s по убыванию</string>
<string name="content_description_create_new_folder">Создать новую папку</string>
<string name="content_description_cancel_button">Отмена</string>
<string name="create_shortcut_dialog_title">Создать ярлык</string>
<string name="create_shortcut_dialog_url">URL</string>
<string name="create_shortcut_dialog_url_error_no_blanks">URL не должен содержать пробелы</string>
<string name="create_shortcut_dialog_url_extension">.url</string>
<string name="open_shortcut_description">Этот ярлык указывает на:</string>
<string name="open_shortcut_title">Открыть ссылку</string>
<string name="create_space_dialog_name">Имя</string>
<string name="create_space_dialog_subtitle">подзаголовок</string>
<string name="create_space_dialog_quota">Квота</string>
<string name="disabled_space_label">Отключена</string>
<string name="members_title">Участники</string>
<string name="link_role_accessibility">Ссылка</string>
<string name="button_role_accessibility">Кнопка</string>
<string name="folder_picker_label">Выбор папки</string>
<string name="passcode_label">Пароль</string>
<string name="pattern_label">Шаблон</string>
<string name="receive_external_files_label">Получить внешние файлы</string>
<string name="video_preview_label">Предварительный просмотр видео</string>
<string name="release_notes_label">Примечания к выпуску</string>
<string name="image_preview_label">Предпросмотр</string>
<string name="login_label">Логин</string>
<string name="whats_new_label">Что нового</string>
<string name="audio_preview_label">Предварительный просмотр аудио</string>
<string name="details_label">Подробно</string>
<string name="text_preview_label">Предварительный просмотр текст файла</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
[*]
max_line_length = 150
[*.{kt, kts}]
disabled_rules=no-consecutive-blank-lines,no-wildcard-imports,import-ordering,max-line-length,import-ordering,no-blank-line-before-rbrace,final-newline,indent,no-multi-spaces,comment-spacing,parameter-list-wrapping
+37
View File
@@ -0,0 +1,37 @@
### Actual behaviour
-Tell us what happens
### Expected behaviour
-Tell us what should happen
### Steps to reproduce
1.
2.
3.
Can this problem be reproduced with the QSfera server?
(url: https://<qsfera-server>)
### Environment data
Android version:
Device model:
Stock or customized system:
Android app version:
Server version:
### Logs
#### Web server error log
```
Insert your webserver log here
```
#### Server log
```
Insert your QSfera server log here
```
+21
View File
@@ -0,0 +1,21 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
labels:
- "Dependencies"
commit-message:
prefix: "feat"
- package-ecosystem: "github-actions"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
commit-message:
prefix: "feat"
+29
View File
@@ -0,0 +1,29 @@
name: Android Unit Tests
on:
pull_request:
branches:
- "*"
jobs:
unit_tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
- name: Build with Gradle
run: ./gradlew assembleDebug
- name: Run unit tests
run: ./gradlew testDebugUnitTest --continue
+112
View File
@@ -0,0 +1,112 @@
name: Build unsigned QSfera APK
on:
workflow_dispatch:
inputs:
build_flavor:
description: "Flavor to build"
type: choice
required: true
default: "qa"
options:
- qa
build_type:
description: "Type to build"
type: choice
required: true
default: "Debug" # uppercase for Gradle build type
options:
- Debug
version:
description: "Version to build (commit, branch or tag)"
type: string
default: "main"
permissions:
contents: write
jobs:
build_apk:
name: Build unsigned QSfera APK
runs-on: ubuntu-latest
outputs:
artifact_name: ${{ steps.set_artifact.outputs.artifact_name }}
commit_hash: ${{ steps.commit.outputs.commit_hash }}
steps:
- name: Checkout current repo
uses: actions/checkout@v6
with:
ref: ${{ inputs.version }}
- name: Get commit hash
id: commit
run: |
COMMIT_HASH=$(git rev-parse HEAD)
echo "Commit hash: $COMMIT_HASH"
echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
echo "commit_hash=$COMMIT_HASH" >> "$GITHUB_ENV"
- name: Setup JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build APK
run: |
FLAVOR="${{ inputs.build_flavor }}"
BUILD_TYPE="${{ inputs.build_type }}"
FLAVOR_TASK="$(tr '[:lower:]' '[:upper:]' <<< "${FLAVOR:0:1}")${FLAVOR:1}"
./gradlew clean assemble${FLAVOR_TASK}${BUILD_TYPE} -PversionCode=${{ github.run_number }}
- name: Set artifact name
id: set_artifact
run: |
DATE=$(date +%Y%m%d_%H%M)
COMMIT=$(git rev-parse --short HEAD)
FLAVOR="${{ inputs.build_flavor }}"
BUILD_TYPE="$(echo "${{ inputs.build_type }}" | tr '[:upper:]' '[:lower:]')"
NAME="QSfera-${FLAVOR}-${COMMIT}-${DATE}-${GITHUB_RUN_NUMBER}.apk"
APK_PATH=$(find ./qsferaApp/build/outputs/apk/$FLAVOR/$BUILD_TYPE/ -name "*.apk" -print -quit)
echo "$APK_PATH"
if [ -z "$APK_PATH" ]; then
echo "APK not found"
exit 1
fi
cp "$APK_PATH" "./$NAME"
echo "$NAME"
# Publish as output
echo "artifact_name=$NAME" >> $GITHUB_OUTPUT
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.set_artifact.outputs.artifact_name }}
path: ./${{ steps.set_artifact.outputs.artifact_name }}
compression-level: 0
- name: Upload APK to release
uses: softprops/action-gh-release@v2
with:
tag_name: build-tester-apk-${{ github.run_number }}
target_commitish: ${{ env.commit_hash }}
name: build-tester-apk build ${{ github.run_number }}
files: ${{ steps.set_artifact.outputs.artifact_name }}
prerelease: true
generate_release_notes: false
body: Built from ${{ env.commit_hash }}
+30
View File
@@ -0,0 +1,30 @@
name: Detekt
# Controls when the action will run. Workflow runs when manually triggered using the UI
# or API.
on:
pull_request:
branches:
- "*"
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
Detekt:
# The type of runner that the job will run on
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
java-version: '17'
distribution: 'temurin'
# Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies.
# See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: detekt execution
run: ./gradlew detekt
+13
View File
@@ -0,0 +1,13 @@
name: "Validate Gradle Wrapper"
on: [pull_request]
permissions:
contents: read
jobs:
validation:
name: "Validation"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: gradle/wrapper-validation-action@v3
+19
View File
@@ -0,0 +1,19 @@
name: "Push source language to Transifex"
on:
push:
branches:
- main
jobs:
push-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: l10n-push-source
uses: transifex/cli-action@v2
with:
token: ${{ secrets.TX_TOKEN }}
args: push -s
+40
View File
@@ -0,0 +1,40 @@
# built application files
*.apk
*.ap_
*.aab
# files for the dex VM
*.dex
# Java class files
*.class
# Local configuration files (sdk path, etc)
local.properties
# Mac .DS_Store files
.DS_Store
# Proguard README
proguard-project.txt
# Android Studio and Gradle specific entries
.gradle
*.iml
build
# android sdk captures folder
captures
# ignore lint html and xml output
lint-*ml
.idea/*
!.idea/codeStyles/
# Prevent exidental commits of build folders
opencloudApp/release
# fastlane stuff (some)
fastlane/README.md
fastlane/report.xml
+379
View File
@@ -0,0 +1,379 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="RIGHT_MARGIN" value="150" />
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
<JavaCodeStyleSettings>
<option name="FIELD_NAME_PREFIX" value="m" />
<option name="STATIC_FIELD_NAME_PREFIX" value="s" />
<option name="CLASS_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99999" />
<option name="NAMES_COUNT_TO_USE_IMPORT_ON_DEMAND" value="99999" />
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" withSubpackages="true" static="false" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<MarkdownNavigatorCodeStyleSettings>
<option name="RIGHT_MARGIN" value="72" />
</MarkdownNavigatorCodeStyleSettings>
<XML>
<option name="XML_ATTRIBUTE_WRAP" value="0" />
<option name="XML_KEEP_BLANK_LINES" value="1" />
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<codeStyleSettings language="JAVA">
<option name="RIGHT_MARGIN" value="150" />
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
<option name="KEEP_CONTROL_STATEMENT_IN_ONE_LINE" value="false" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<option name="WRAP_LONG_LINES" value="true" />
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<FINAL>true</FINAL>
<PUBLIC>true</PUBLIC>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<FINAL>true</FINAL>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<PUBLIC>true</PUBLIC>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<INITIALIZER_BLOCK>true</INITIALIZER_BLOCK>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<FINAL>true</FINAL>
<PUBLIC>true</PUBLIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<FINAL>true</FINAL>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<FIELD>true</FIELD>
<PUBLIC>true</PUBLIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<FIELD>true</FIELD>
</match>
</rule>
</section>
<section>
<rule>
<match>
<INITIALIZER_BLOCK>true</INITIALIZER_BLOCK>
</match>
</rule>
</section>
<section>
<rule>
<match>
<CONSTRUCTOR>true</CONSTRUCTOR>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<METHOD>true</METHOD>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<METHOD>true</METHOD>
</match>
</rule>
</section>
<section>
<rule>
<match>
<ENUM>true</ENUM>
</match>
</rule>
</section>
<section>
<rule>
<match>
<INTERFACE>true</INTERFACE>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<CLASS>true</CLASS>
<STATIC>true</STATIC>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<CLASS>true</CLASS>
</match>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_NAMESPACE>Namespace:</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_NAMESPACE>Namespace:</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_width</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_height</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:layout_.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:width</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:height</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="1" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+14
View File
@@ -0,0 +1,14 @@
[main]
host = https://www.transifex.com
[o:opencloud-eu:p:opencloud-eu:r:android]
file_filter = opencloudApp/src/main/res/values-<lang>/strings.xml
source_file = opencloudApp/src/main/res/values/strings.xml
source_lang = en
lang_map = zh_HK: zh-rHK, ar_JO: ar-rJO, es_SV: es-rSV, fa_IR: fa-rIR, ky_KG: ky-rKG, ne_NP: ne-rNP, pt_PT: pt-rPT, arn_CL: arn-rCL, de_AT: de-rAT, es_ES: es-rES, es_US: es-rUS, lb_LU: lb-rLU, ta_IN: ta-rIN, tr_TR: tr-rTR, as_IN: as-rIN, az_AZ: az-rAZ, en_IE: en-rIE, es_CL: es-rCL, gu_IN: gu-rIN, ps_AF: ps-rAF, dsb_DE: dsb-rDE, prs_AF: prs-rAF, quz_EC: quz-rEC, se_FI: se-rFI, ug_CN: ug-rCN, es_VE: es-rVE, lo_LA: lo-rLA, nl_BE: nl-rBE, sq_AL: sq-rAL, yo_NG: yo-rNG, af_ZA: af-rZA, ar_MA: ar-rMA, es_GT: es-rGT, tzm_DZ: tzm-rDZ, bs_BA: bs-rBA, en_CA: en-rCA, en_IN: en-rIN, ja_JP: ja-rJP, lv_LV: lv-rLV, sr_ME: sr-rME, uk_UA: uk-rUA, de_CH: de-rCH, en_PH: en-rPH, es_MX: es-rMX, ga_IE: ga-rIE, sr@latin: sr-rSP, ar_YE: ar-rYE, be_BY: be-rBY, en_AU: en-rAU, en@pirate: en-rpirate, fy_NL: fy-rNL, hu_HU: hu-rHU, hy_AM: hy-rAM, rw_RW: rw-rRW, id_ID: id-rID, ig_NG: ig-rNG, iu_CA: iu-rCA, my_MM: my, oc_FR: oc-rFR, quz_BO: quz-rBO, se_NO: se-rNO, smj_SE: smj-rSE, ar_EG: ar-rEG, cs_CZ: cs-rCZ, kn_IN: kn-rIN, syr_SY: syr-rSY, zh_MO: zh-rMO, de_LU: de-rLU, kk_KZ: kk-rKZ, mk_MK: mk-rMK, sr_BA: sr-rBA, sr_RS: sr-rRS, vi_VN: vi-rVN, ar_SA: ar-rSA, ca_ES: ca-rES, cy_GB: cy-rGB, es_AR: es-rAR, fr_LU: fr-rLU, mn_MN: mn-rMN, mr_IN: mr-rIN, smj_NO: smj-rNO, br_FR: br-rFR, es_PE: es-rPE, eu_ES: eu-rES, fi_FI: fi-rFI, sw_KE: sw-rKE, tt_RU: tt-rRU, ar_AE: ar-rAE, ar_IQ: ar-rIQ, en_SG: en-rSG, es_EC: es-rEC, hsb_DE: hsb-rDE, sv_SE: sv-rSE, ur_PK: ur-rPK, am_ET: am-rET, es_DO: es-rDO, ku_IQ: ku-rIQ, mi_NZ: mi-rNZ, or_IN: or-rIN, zu_ZA: zu-rZA, es_CR: es-rCR, es_HN: es-rHN, es_PR: es-rPR, et_EE: et-rEE, hi_IN: hi-rIN, ms_BN: ms-rBN, se_SE: se-rSE, xh_ZA: xh-rZA, en_US: en-rUS, hr_BA: hr-rBA, is_IS: is-rIS, it_CH: it-rCH, nb_NO: nb-rNO, sa_IN: sa-rIN, sl_SI: sl-rSI, tg_TJ: tg-rTJ, ar_LB: ar-rLB, es_UY: es-rUY, ha_NG: ha-rNG, ii_CN: ii-rCN, ms_MY: ms-rMY, smn_FI: smn-rFI, ta_LK: ta-rLK, tn_ZA: tn-rZA, ar_BH: ar-rBH, de_DE: de-rDE, dv_MV: dv-rMV, en_BZ: en-rBZ, fr_CH: fr-rCH, he_IL: he-rIL, hr_HR: hr-rHR, nso_ZA: nso-rZA, es_NI: es-rNI, pa_IN: pa-rIN, pl_PL: pl-rPL, quz_PE: quz-rPE, sk_SK: sk-rSK, th_TH: th-rTH, uz_UZ: uz-rUZ, zh_CN: zh-rCN, de_LI: de-rLI, el_GR: el-rGR, fo_FO: fo-rFO, gsw_FR: gsw-rFR, tk_TM: tk-rTM, zh_CN.GB2312: zh-rBG, zh_TW: zh-rTW, en_GB: en-rGB, en_ZW: en-rZW, mn_CN: mn-rCN, ru_RU: ru-rRU, es_PA: es-rPA, es_PY: es-rPY, gl_ES: gl-rES, rm_CH: rm-rCH, si_LK: si-rLK, sr_CS: sr-rCS, bg_BG: bg-rBG, en_ZA: en-rZA, fr_CA: fr-rCA, ro_RO: ro-rRO, sv_FI: sv-rFI, ar_OM: ar-rOM, bn_BD: bn-rBD, bn_IN: bn-rIN, es_BO: es-rBO, fil_PH: fil-rPH, mt_MT: mt-rMT, qut_GT: qut-rGT, sms_FI: sms-rFI, ba_RU: ba-rRU, en_NZ: en-rNZ, en_TT: en-rTT, fr_BE: fr-rBE, it_IT: it-rIT, te_IN: te-rIN, co_FR: co-rFR, fr_FR: fr-rFR, gd_GB: gd-rGB, ko_KR: ko-rKR, lt_LT: lt-rLT, moh_CA: moh-rCA, nl_NL: nl-rNL, sma_SE: sma-rSE, ar_SY: ar-rSY, bo_CN: bo-rCN, es_CO: es-rCO, km_KH: km-rKH, wo_SN: wo-rSN, zh_SG: zh-rSG, ar_DZ: ar-rDZ, ar_LY: ar-rLY, ar_QA: ar-rQA, nn_NO: nn-rNO, sma_NO: sma-rNO, en_JM: en-rJM, en_MY: en-rMY, ka_GE: ka-rGE, kok_IN: kok-rIN, ml_IN: ml-rIN, pt_BR: pt-rBR, sah_RU: sah-rRU, ar_KW: ar-rKW, ar_TN: ar-rTN, da_DK: da-rDK, fr_MC: fr-rMC, kl_GL: kl-rGL
replace_edited_strings = false
keep_translations = false
type = ANDROID
minimum_perc = 20
resource_name = android
+19
View File
@@ -0,0 +1,19 @@
## Contributing
Use short-lived branches from `main` and keep changes scoped to one feature, fix, or maintenance task.
Suggested branch prefixes:
* `feature/`
* `fix/`
* `improvement/`
* `technical/`
Before opening a pull request, run the relevant Gradle build and test tasks for the modules you changed.
Example:
```powershell
.\gradlew.bat :qsferaApp:assembleOriginalDebug
.\gradlew.bat :qsferaComLibrary:testDebugUnitTest :qsferaDomain:testDebugUnitTest :qsferaData:testDebugUnitTest :qsferaApp:testOriginalDebugUnitTest
```
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2,
as published by the Free Software Foundation.
This program 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 this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+26
View File
@@ -0,0 +1,26 @@
# КуСфера Android app
Android client project branded as `КуСфера`.
## Build
The Gradle modules are:
* `:qsferaApp`
* `:qsferaDomain`
* `:qsferaData`
* `:qsferaComLibrary`
* `:qsferaTestUtil`
Typical local checks:
```powershell
.\gradlew.bat :qsferaApp:assembleOriginalDebug
.\gradlew.bat :qsferaComLibrary:testDebugUnitTest :qsferaDomain:testDebugUnitTest :qsferaData:testDebugUnitTest :qsferaApp:testOriginalDebugUnitTest
```
The Android application package is `eu.qsfera.android`.
## Notes
Public store, support, privacy-policy, and help URLs are intentionally not configured in app resources until verified QSfera URLs are available.
+24
View File
@@ -0,0 +1,24 @@
### Setup Information
These instructions describe how to build the КуСфера Android project locally.
### Requirements
* Git.
* Android Studio or the Android Gradle command line environment.
* JDK compatible with the Android Gradle Plugin used by this project.
* Android SDK with the compile SDK configured in `build.gradle`.
### Android Studio
Open this repository as an existing Gradle project. Android Studio should import the modules from `settings.gradle`.
### Command Line
From the repository root:
```powershell
.\gradlew.bat :qsferaApp:assembleOriginalDebug
```
If Gradle cannot find Java or the Android SDK, set `JAVA_HOME`, `ANDROID_HOME`, and `ANDROID_SDK_ROOT` for your local machine.
+80
View File
@@ -0,0 +1,80 @@
###################################################################
QSfera Android client
Copyright (C) 2025 QSfera
Copyright (C) 2020 ownCloud GmbH.
Copyright (C) 2012 Bartek Przybylski
###################################################################
###########
# LICENSE #
###########
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2,
as published by the Free Software Foundation.
This program 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.
The source distribution of this program should include a full copy
of the GNU GPL version 2 license in the LICENSE.txt file located
in its root directory. If not, see <http://www.gnu.org/licenses/>.
########################
# THIRD PARTY LICENSES #
########################
Both the source and binary distributions of this software contain
some third party software. All the third party software included
or linked is redistributed under the terms and conditions of their
original licenses. These licenses are compatible the GPL license
that govern this software, for the purposes they are being used.
The third party software included and used by this project is:
* Transifex client.
Copyright (C) Transifex.
Licensed under GNU General Public License.
Placed at third_party/transifex-client.
Used as a helper tool, not included in the QSfera client APK.
Original license document included at third_party/transifex-client/LICENSE.
See http://help.transifex.com/features/client/
* floatingactionbutton 1.10.1.
Copyright (c) 2014 Jerzy Chalupski
Licensed under Apache License, Version 2.0.
placed at libs/com-getbase-floatingactionbutton-1-10-0-exploded-aar has been exploded by ownCloud GmbH.
See https://github.com/futuresimple/android-floating-action-button
* PatternLockView, version 1.0.0
Copyright (C) 2017 aritraroy
Licensed under Apache License, Version 2.0.
Included in the QSfera client APK.
See https://github.com/aritraroy/PatternLockView
* Glide, version 4.6.1
Copyright (C) 2014 Google, Inc.
Licensed under BSD, part MIT and Apache 2.0.
Included in the QSfera client APK.
See https://github.com/bumptech/glide
* PhotoView, version 2.0.0
Copyright (C) 2017 Chris Banes
Licensed under Apache License, Version 2.0.
Included in the QSfera client APK.
See https://github.com/chrisbanes/PhotoView
* OkHttp, version 4.6.0
Licensed under Apache License, Version 2.0.
Included in the QSfera client APK.
See https://github.com/square/okhttp
* Dav4Jvm
Licensed under Mozilla Public License, Version 2.0.
Included in the QSfera client APK.
See https://gitlab.com/bitfireAT/dav4jvm
+5
View File
@@ -0,0 +1,5 @@
# Troubleshooting
No verified public QSfera troubleshooting URL is configured in this repository.
For local build setup, see `SETUP.md`.
+56
View File
@@ -0,0 +1,56 @@
buildscript {
ext {
// SDK
sdkCompileVersion = 35
sdkMinVersion = 26
sdkTargetVersion = 35
}
repositories {
google()
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath libs.android.gradlePlugin
classpath libs.kotlin.gradlePlugin
classpath libs.ktlint.gradlePlugin
}
}
plugins {
alias libs.plugins.sonarqube
alias libs.plugins.ksp apply false
alias libs.plugins.detekt
}
allprojects {
repositories {
google()
mavenCentral()
maven {
url "https://jitpack.io"
}
}
}
subprojects {
apply plugin: "com.google.devtools.ksp"
//apply plugin: "org.jlleitschuh.gradle.ktlint"
//apply plugin: "org.sonarqube"
apply plugin: "io.gitlab.arturbosch.detekt"
}
//sonarqube {
// properties {
// property "sonar.projectKey", "qsfera_android"
// property "sonar.organization", "qsfera-1"
// property "sonar.projectVersion", getBranchName()
// property "sonar.host.url", "https://sonarcloud.io"
// }
//}
def getBranchName() {
def name = "git rev-parse --abbrev-ref HEAD".execute()
return name.text.toString().trim()
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
check_license_in_file() {
if ! head -n 20 $FILE | grep -q "This program is free software: you can redistribute it and/or modify"
then
if ! head -n 20 $FILE | grep -q "Licensed under the Apache License, Version 2.0"
then
echo "$FILE does not contain a current copyright header"
fi
fi
}
for DIRS in qsferaApp/src qsferaData/src qsferaDomain/src qsferaTestUtil/src
do
for FILE in $(find $DIRS -name "*.java" -o -name "*.kt")
do
check_license_in_file
done
done
./gradlew ktlintFormat
+927
View File
@@ -0,0 +1,927 @@
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: false
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
# - 'KtFileCountProcessor'
# - 'PackageCountProcessor'
# - 'ClassCountProcessor'
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ProjectComplexityProcessor'
# - 'ProjectCognitiveComplexityProcessor'
# - 'ProjectLLOCProcessor'
# - 'ProjectCLOCProcessor'
# - 'ProjectLOCProcessor'
# - 'ProjectSLOCProcessor'
# - 'LicenseHeaderLoaderExtension'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FindingsReport'
- 'FileBasedFindingsReport'
# - 'LiteFindingsReport'
output-reports:
active: true
exclude:
# - 'TxtOutputReport'
# - 'XmlOutputReport'
# - 'HtmlOutputReport'
comments:
active: false
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
licenseTemplateIsRegex: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
KDocReferencesNonPublicProperty:
active: false
OutdatedDocumentation:
active: false
matchTypeParameters: true
matchDeclarationsOrder: true
allowParamOnConstructorProperties: false
UndocumentedPublicClass:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
UndocumentedPublicFunction:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
UndocumentedPublicProperty:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
complexity:
active: true
CognitiveComplexMethod:
active: false
threshold: 20
ComplexCondition:
active: true
threshold: 6
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
CyclomaticComplexMethod:
active: false
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'also'
- 'apply'
- 'forEach'
- 'isNotNull'
- 'ifNull'
- 'let'
- 'run'
- 'use'
- 'with'
LabeledExpression:
active: false
ignoredLabels: []
LargeClass:
active: false
threshold: 600
LongMethod:
active: true
threshold: 100
LongParameterList:
active: false
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotatedParameter: []
MethodOverloading:
active: true
threshold: 6
NamedArguments:
active: false
threshold: 3
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: false
threshold: 5
NestedScopeFunctions:
active: false
threshold: 1
functions: ['kotlin.apply', 'kotlin.run', 'kotlin.with', 'kotlin.let', 'kotlin.also']
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: true
InjectDispatcher:
active: false
dispatcherNames:
- 'IO'
- 'Default'
- 'Unconfined'
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunWithCoroutineScopeReceiver:
active: true
SuspendFunWithFlowReturnType:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: true
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: false
methodNames:
- 'equals'
- 'finalize'
- 'hashCode'
- 'toString'
InstanceOfCheckForException:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
NotImplementedDeclaration:
active: true
ObjectExtendsThrowable:
active: true
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- 'InterruptedException'
- 'MalformedURLException'
- 'NumberFormatException'
- 'ParseException'
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Exception'
- 'IllegalArgumentException'
- 'IllegalMonitorStateException'
- 'IllegalStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
- 'Exception'
- 'IllegalMonitorStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: false
exceptionNames:
- 'Error'
- 'Exception'
- 'RuntimeException'
- 'Throwable'
formatting:
active: false
AnnotationOnSeparateLine:
active: true
indentSize: 4
AnnotationSpacing:
active: true
ArgumentListWrapping:
active: true
indentSize: 4
maxLineLength: 120
BlockCommentInitialStarAlignment:
active: true
ChainWrapping:
active: true
indentSize: 4
ClassName:
active: false
CommentSpacing:
active: true
CommentWrapping:
active: true
indentSize: 4
ContentReceiverMapping:
active: false
maxLineLength: 120
indentSize: 4
DiscouragedCommentLocation:
active: false
EnumEntryNameCase:
active: true
EnumWrapping:
active: false
intentSize: 4
Filename:
active: true
FinalNewline:
active: true
insertFinalNewLine: true
FunKeywordSpacing:
active: true
FunctionName:
active: false
FunctionReturnTypeSpacing:
active: true
maxLineLength: 120
FunctionSignature:
active: false
forceMultilineWhenParameterCountGreaterOrEqualThan: 2147483647
functionBodyExpressionWrapping: 'default'
maxLineLength: 120
indentSize: 4
FunctionStartOfBodySpacing:
active: true
FunctionTypeReferenceSpacing:
active: true
IfElseBracing:
active: false
IfElseWrapping:
active: false
indentSize: 4
ImportOrdering:
active: true
layout: '*,java.**,javax.**,kotlin.**,^'
Indentation:
active: true
indentSize: 4
KdocWrapping:
active: true
indentSize: 4
MaximumLineLength:
active: true
maxLineLength: 120
ignoreBackTickedIdentifier: false
ModifierListSpacing:
active: true
MultiLineIfElse:
active: true
indentSize: 4
MultilineExpressionWrapping:
active: false
indentSize: 4
NoBlankLineBeforeRbrace:
active: true
NoBlankLineInList:
active: false
NoBlankLinesInChainedMethodCalls:
active: true
NoConsecutiveBlankLines:
active: true
NoConsecutiveComments:
active: false
NoEmptyClassBody:
active: true
NoEmptyFirstLineInClassBody:
active: false
indentSize: 4
NoEmptyFirstLineInMethodBlock:
active: true
NoLineBreakAfterElse:
active: true
NoLineBreakBeforeAssignment:
active: true
NoMultipleSpaces:
active: true
NoSemicolons:
active: true
NoSingleLineBlockComment:
active: false
indentSize: 4
NoTrailingSpaces:
active: true
NoUnitReturn:
active: true
NoUnusedImports:
active: true
NoWildcardImports:
active: true
packagesToUseImportOnDemandProperty: 'java.util.*,kotlinx.android.synthetic.**'
NullableTypeSpacing:
active: true
PackageName:
active: true
ParameterListSpacing:
active: false
ParameterListWrapping:
active: true
maxLineLength: 120
indentSize: 4
ParameterWrapping:
active: true
indentSize: 4
MaxLineLength: 120
PropertyName:
active: false
PropertyWrapping:
active: true
indentSize: 4
maxLineLength: 120
SpacingAroundAngleBrackets:
active: true
SpacingAroundColon:
active: true
SpacingAroundComma:
active: true
SpacingAroundCurly:
active: true
SpacingAroundDot:
active: true
SpacingAroundDoubleColon:
active: true
SpacingAroundKeyword:
active: true
SpacingAroundOperators:
active: true
SpacingAroundParens:
active: true
SpacingAroundRangeOperator:
active: true
SpacingAroundUnaryOperator:
active: true
SpacingBetweenDeclarationsWithAnnotations:
active: true
SpacingBetweenDeclarationsWithComments:
active: true
SpacingBetweenFunctionNameAndOpeningParenthesis:
active: true
StringTemplate:
active: true
StringTemplateIndent:
active: false
indentSize: 4
TrailingCommaOnCallSite:
active: false
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: false
useTrailingCommaOnDeclarationSite: true
TryCatchFinallySpacing:
active: false
indentSize: 4
TypeArgumentListSpacing:
active: false
indentSize: 4
TypeParameterListSpacing:
active: false
indentSize: 4
UnnecessaryParenthesesBeforeTrailingLambda:
active: true
Wrapping:
active: true
indentSize: 4
maxLineLength: 120
libraries:
active: false
ForbiddenPublicDataClass:
active: true
ignorePackages: ['*.internal', '*.internal.*']
LibraryCodeMustSpecifyReturnType:
active: true
allowOmitUnit: false
LibraryEntitiesShouldNotBePublic:
active: true
naming:
active: true
BooleanPropertyNaming:
active: false
allowedPattern: '^(is|has|are)'
ClassNaming:
active: true
classPattern: '[A-Z][a-zA-Z0-9]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
forbiddenName: []
FunctionMaxLength:
active: false
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
functionPattern: '[a-z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
FunctionParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
rootPackage: ''
requireRootInDeclaration: false
LambdaParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*|_'
MatchingDeclarationName:
active: true
mustBeFirst: false
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NoNameShadowing:
active: true
NonBooleanPropertyPrefixedWithIs:
active: false
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
maximumVariableNameLength: 64
VariableMinLength:
active: false
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
CouldBeSequence:
active: false
threshold: 3
ForEachOnRange:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
SpreadOperator:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
UnnecessaryPartOfBinaryExpression:
active: true
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
AvoidReferentialEquality:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: true
CastToNullableType:
active: false
Deprecation:
active: false
DontDowncastCollectionTypes:
active: true
DoubleMutabilityForCollection:
active: true
mutableTypes:
- 'kotlin.collections.MutableList'
- 'kotlin.collections.MutableMap'
- 'kotlin.collections.MutableSet'
- 'java.util.ArrayList'
- 'java.util.LinkedHashSet'
- 'java.util.HashSet'
- 'java.util.LinkedHashMap'
- 'java.util.HashMap'
ElseCaseInsteadOfExhaustiveWhen:
active: true
ignoredSubjectTypes: []
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: true
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: true
IgnoredReturnValue:
active: false
restrictToConfig: true
returnValueAnnotations:
- '*.CheckResult'
- '*.CheckReturnValue'
ignoreReturnValueAnnotations:
- '*.CanIgnoreReturnValue'
ignoreFunctionCall: []
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: true
allowExplicitReturnType: false
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: true
MissingPackageDeclaration:
active: true
NullCheckOnMutableProperty:
active: false
NullableToStringCall:
active: true
PropertyUsedBeforeDeclaration:
active: true
UnconditionalJumpStatementInLoop:
active: true
UnnecessaryNotNullCheck:
active: true
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: true
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: false
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
WrongEqualsTypeParameter:
active: true
style:
active: true
AlsoCouldBeApply:
active: true
BracesOnIfStatements:
active: true
singleLine: 'consistent'
multiLine: 'consistent'
BracesOnWhenStatements:
active: true
singleLine: 'consistent'
multiLine: 'consistent'
CanBeNonNullable:
active: true
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering:
active: true
CollapsibleIfStatements:
active: true
DataClassContainsFunctions:
active: false
allowOperators: false
conversionFunctionPrefix: ['to']
DataClassShouldBeImmutable:
active: false
DestructuringDeclarationWithTooManyEntries:
active: false
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: true
negativeFunctions: ['takeUnless', 'none']
negativeFunctionNameParts: ['not', 'non']
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: true
ExplicitCollectionElementAccessMethod:
active: true
ExplicitItLambdaParameter:
active: true
ExpressionBodySyntax:
active: true
includeLineWrapping: true
ForbiddenAnnotation:
active: false
annotations: ['java.lang.SuppressWarnings', 'java.lang.Deprecated', 'java.lang.annotation.Documented', 'java.lang.annotation.Target', 'java.lang.annotation.Retention', 'java.lang.annotation.Repeatable', 'java.lang.annotation.Inherited']
ForbiddenComment:
active: true
allowedPatterns: ''
#comments: ['FIXME:', 'STOPSHIP:', 'TODO:']
comments: ['STOPSHIP:']
ForbiddenImport:
active: false
imports: []
forbiddenPatterns: ''
ForbiddenMethodCall:
active: true
methods: ['kotlin.io.print', 'kotlin.io.println']
ForbiddenSuppress:
active: false
rules: []
ForbiddenVoid:
active: true
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: []
LoopWithTooManyJumpStatements:
active: false
maxJumpCount: 1
MagicNumber:
active: false
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesLoops:
active: true
MaxLineLength:
active: true
maxLineLength: 150
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: false
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
active: false
MultilineRawStringIndentation:
active: false
indentSize: 4
trimmingMethods: ['trimIndent', 'trimMargin']
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
active: false
NullableBooleanCheck:
active: true
ObjectLiteralToLambda:
active: true
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: true
PreferToOverPairSyntax:
active: true
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: true
RedundantHigherOrderMapUsage:
active: true
RedundantVisibilityModifierRule:
active: true
ReturnCount:
active: false
max: 2
excludedFunctions: ['equals']
excludeLabeled: false
excludeReturnFromLambda: true
excludeGuardClauses: false
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: true
StringShouldBeRawString:
active: false
maxEscapedCharacterCount: 2
ignoredCharacters: []
ThrowsCount:
active: false
max: 2
excludeGuardClauses: false
TrailingWhitespace:
active: true
TrimMultilineRawString:
active: false
trimmingMethods: ['trimIndent', 'trimMargin']
UnderscoresInNumericLiterals:
active: false
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAbstractClass:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: false
UnnecessaryApply:
active: true
UnnecessaryBackticks:
active: false
UnnecessaryBracesAroundTrailingLambda:
active: true
UnnecessaryFilter:
active: true
UnnecessaryInheritance:
active: true
UnnecessaryInnerClass:
active: true
UnnecessaryLet:
active: true
UnnecessaryParentheses:
active: false
allowForUnclearPrecedence: true
UntilInsteadOfRangeTo:
active: true
UnusedImports:
active: true
UnusedParameter:
active: true
allowedNames: 'ignored|expected'
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: '(_|ignored|expected|serialVersionUID)'
UnusedPrivateProperty:
active: true
allowedNames: '_|ignored|expected|serialVersionUID'
UseAnyOrNoneInsteadOfFind:
active: true
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
active: false
UseCheckOrError:
active: false
UseDataClass:
active: true
allowVars: true
UseEmptyCounterpart:
active: true
UseIfEmptyOrIfBlank:
active: true
UseIfInsteadOfWhen:
active: true
ignoreWhenContainingVariableDeclaration: false
UseIsNullOrEmpty:
active: true
UseLet:
active: true
UseOrEmpty:
active: true
UseRequire:
active: false
UseRequireNotNull:
active: false
UseSumOfInsteadOfFlatMapSize:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
ignoreLateinitVar: false
WildcardImport:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
excludeImports:
- 'java.util.*'
+12
View File
@@ -0,0 +1,12 @@
Welcome to Gradle 7.5!
Here are the highlights of this release:
- Support for Java 18
- Support for building with Groovy 4
- Much more responsive continuous builds
- Improved diagnostics for dependency resolution
For more details see https://docs.gradle.org/7.5/release-notes.html
Starting a Gradle Daemon (subsequent builds will be faster)
+6
View File
@@ -0,0 +1,6 @@
package_name("eu.qsfera.android")
# Credentials are read from environment, NOT a file in the repo:
# SUPPLY_JSON_KEY — absolute path to the service-account JSON (local dev)
# SUPPLY_JSON_KEY_DATA — raw JSON contents (CI; e.g. a GitHub Actions secret)
# Set whichever one fits your workflow.
+61
View File
@@ -0,0 +1,61 @@
default_platform(:android)
def qsfera_env(primary, legacy)
ENV[primary].to_s.empty? ? ENV[legacy] : ENV[primary]
end
platform :android do
desc "Pull latest translations from Transifex"
lane :pull_translations do
Dir.chdir("..") do
sh("tx", "pull", "-a", "-f", "--skip")
#sh("tx" "pull" "--force" "--skip" "--all" "-w" "1" "-m" "onlytranslated")
end
files = Dir.glob("../qsferaApp/src/main/res/values-*/strings.xml")
if files.empty?
UI.important("No values-*/strings.xml present after tx pull — nothing was downloaded.")
else
UI.success("#{files.size} translation file(s) on disk:")
files.sort.each { |f| UI.message(" #{f}") }
end
end
desc "Build the originalRelease AAB (pulls translations first)"
lane :build_release do
{
"QSFERA_RELEASE_KEYSTORE" => "OC_RELEASE_KEYSTORE",
"QSFERA_RELEASE_KEYSTORE_PASSWORD" => "OC_RELEASE_KEYSTORE_PASSWORD",
"QSFERA_RELEASE_KEY_ALIAS" => "OC_RELEASE_KEY_ALIAS",
"QSFERA_RELEASE_KEY_PASSWORD" => "OC_RELEASE_KEY_PASSWORD"
}.each do |primary, legacy|
next unless qsfera_env(primary, legacy).to_s.empty?
UI.user_error!(
"#{primary} is not set — release AAB would be unsigned and rejected by Play. " \
"#{legacy} is still accepted as a legacy fallback."
)
end
pull_translations
gradle(
task: "bundle",
flavor: "Original",
build_type: "Release"
)
sh("ls", "-l", "../qsferaApp/build/outputs/bundle/originalRelease/qsferaApp-original-release.aab")
end
desc "Pull translations, build, and upload to Play Store. Override track with: fastlane deploy track:beta"
lane :deploy do |options|
track = options[:track] || "internal"
build_release
upload_to_play_store(
track: track,
aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH],
skip_upload_apk: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
@@ -0,0 +1,13 @@
Excellent file management and collaboration for public authorities, providers and business or anyone who values ease of use and digital sovereignty.
File Sync & Share
Changes to documents are synchronized in real time, ensuring all team members always have access to the latest version.
Workspaces
Create data rooms that enable access for all team members and promote efficient collaboration. Files and folders can be organized, managed and securely shared in these central areas.
Smart Search
The full-text and metadata search allows you to browse through all documents and files, quickly discovering relevant information. Whether youre searching for specific terms within the text or metadata such as creation date or author, finding what you need is effortless.
Web Office
With QSfera's integrated office applications, teams can work on documents in real time whether its text, spreadsheets, or presentations.
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

@@ -0,0 +1 @@
File Sync, Workspaces, Smart Search & Web Office
+7
View File
@@ -0,0 +1,7 @@
android.enableJetifier=true
android.nonFinalResIds=false
android.nonTransitiveRClass=false
android.useAndroidX=true
org.gradle.jvmargs=-Xmx1536M
android.overridePathCheck=true
+133
View File
@@ -0,0 +1,133 @@
[versions]
androidGradlePlugin = "8.7.2"
androidxActivity = "1.8.0"
androidxAnnotation = "1.6.0"
androidxAppCompat = "1.6.1"
androidxArch = "2.2.0"
androidxBiometric = "1.1.0"
androidxBrowser = "1.5.0"
androidxContraintLayout = "2.1.4"
androidxCore = "1.10.1"
androidxEnterpriseFeedback = "1.1.0"
androidxEspresso = "3.6.1"
androidxFragment = "1.5.7"
androidxLegacy = "1.0.0"
androidxLifecycle = "2.5.1"
androidxLifecycleExtensions = "2.2.0"
androidxRoom = "2.5.1"
androidxSqlite = "2.3.1"
androidxTest = "1.6.1"
androidxTestExt = "1.2.1"
androidxTestMonitor = "1.7.2"
androidxTestUiAutomator ="2.3.0"
androidxWork = "2.8.1"
coil = "2.2.2"
detekt = "1.23.8"
dexopener = "2.0.5"
disklrucache = "2.0.2"
media3 ="1.1.1"
floatingactionbutton = "1.10.1"
glide = "4.15.1"
glideToVectorYou = "v2.0.0"
junit4 = "4.13.2"
kaspresso = "1.6.1"
koin = "3.3.3"
kotlin = "1.9.20"
kotlinxCoroutines = "1.6.4"
ksp = "1.9.20-1.0.14"
ktlint = "11.1.0"
markwon = "4.6.2"
material = "1.8.0"
mockk = "1.13.13"
moshi = "1.15.2"
patternlockview = "a90b0d4bf0"
photoView = "2.3.0"
preference = "1.2.0"
sonarqube = "4.0.0.2929"
stetho = "1.6.0"
timber = "5.0.1"
[libraries]
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidxActivity" }
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }
androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArch" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometric" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidxContraintLayout" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-enterprise-feedback = { group = "androidx.enterprise", name = "enterprise-feedback", version.ref = "androidxEnterpriseFeedback" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" }
androidx-fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "androidxFragment" }
androidx-legacy-support = { group = "androidx.legacy", name = "legacy-support-v4", version.ref = "androidxLegacy" }
androidx-lifecycle-common-java8 = { group = "androidx.lifecycle", name = "lifecycle-common-java8", version.ref = "androidxLifecycle" }
androidx-lifecycle-extensions = { group = "androidx.lifecycle", name = "lifecycle-extensions", version.ref = "androidxLifecycleExtensions" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidxRoom" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidxRoom" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidxRoom" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "androidxRoom" }
androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.ref = "androidxSqlite" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTest" }
androidx-test-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "androidxEspresso" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
androidx-test-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "androidxEspresso" }
androidx-test-espresso-web = { group = "androidx.test.espresso", name = "espresso-web", version.ref = "androidxEspresso" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-test-monitor = { group = "androidx.test", name = "monitor", version.ref = "androidxTestMonitor" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" }
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxTestUiAutomator" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-libraries = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" }
dexopener = { group = "com.github.tmurakami", name = "dexopener", version.ref = "dexopener" }
disklrucache = { group = "com.jakewharton", name = "disklrucache", version.ref = "disklrucache" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
floatingactionbutton = { group = "com.getbase", name = "floatingactionbutton", version.ref = "floatingactionbutton" }
glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" }
glide-vector = { group = "com.github.2coffees1team", name = "GlideToVectorYou", version.ref = "glideToVectorYou" }
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin" }
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "markwon" }
markwon-ext-strikethrough = { group = "io.noties.markwon", name = "ext-strikethrough", version.ref = "markwon" }
markwon-ext-tables = { group = "io.noties.markwon", name = "ext-tables", version.ref = "markwon" }
markwon-ext-tasklist = { group = "io.noties.markwon", name = "ext-tasklist", version.ref = "markwon" }
markwon-html = { group = "io.noties.markwon", name = "html", version.ref = "markwon" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
patternlockview = { group = "com.github.aritraroy.PatternLockView", name = "patternlockview", version.ref = "patternlockview" }
photoview = { group = "com.github.chrisbanes", name = "PhotoView", version.ref = "photoView" }
stetho = { group = "com.facebook.stetho", name = "stetho", version.ref = "stetho" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
# Dependencies of the included build-logic
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
ktlint-gradlePlugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlint" }
[bundles]
espresso = ["androidx-test-espresso-contrib", "androidx-test-espresso-core", "androidx-test-espresso-intents", "androidx-test-espresso-web"]
markwon = ["markwon-core", "markwon-ext-tables", "markwon-ext-strikethrough", "markwon-ext-tasklist", "markwon-html"]
[plugins]
kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+234
View File
@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+2
View File
@@ -0,0 +1,2 @@
/.gradle
/.build
+8
View File
@@ -0,0 +1,8 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
google()
}
+10
View File
@@ -0,0 +1,10 @@
dependencyResolutionManagement {
repositories {
mavenCentral()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
+36
View File
@@ -0,0 +1,36 @@
-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgentHelper
-keep public class * extends android.preference.Preference
-keep public class com.android.vending.licensing.ILicensingService
-keepclasseswithmembernames class * {
native <methods>;
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet);
}
-keepclasseswithmembers class * {
public <init>(android.content.Context, android.util.AttributeSet, int);
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
+302
View File
@@ -0,0 +1,302 @@
apply plugin: 'com.android.application'
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
def commitSHA1 = 'COMMIT_SHA1'
def gitRemote = 'GIT_REMOTE'
def envValue(String primary, String legacy = null) {
def value = System.getenv(primary)
if (value) {
return value
}
return legacy ? System.getenv(legacy) : null
}
dependencies {
// Data and domain modules
implementation project(':qsferaDomain')
implementation project(':qsferaData')
// Kotlin
implementation libs.kotlin.stdlib
implementation libs.kotlinx.coroutines.core
// Android X
implementation libs.androidx.activity
implementation libs.androidx.annotation
implementation libs.androidx.appcompat
implementation libs.androidx.biometric
implementation libs.androidx.constraintlayout
implementation libs.androidx.core.ktx
implementation libs.androidx.fragment.ktx
implementation libs.androidx.legacy.support
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.lifecycle.extensions
implementation libs.androidx.lifecycle.livedata.ktx
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.viewmodel.ktx
implementation libs.androidx.preference.ktx
implementation libs.androidx.room.runtime
implementation libs.androidx.sqlite.ktx
implementation libs.androidx.work.runtime.ktx
implementation(libs.androidx.browser) { because "CustomTabs required for OAuth2 and OIDC" }
implementation(libs.androidx.enterprise.feedback) { because "MDM feedback" }
// Image loading
implementation libs.coil
implementation libs.glide
implementation libs.glide.vector
// Zooming Android ImageView.
implementation libs.photoview
// Koin dependency injector
implementation libs.koin.androidx.workmanager
implementation libs.koin.core
// Miscellaneous
implementation libs.disklrucache
implementation libs.media3.exoplayer
implementation libs.media3.ui
implementation libs.floatingactionbutton
implementation libs.material
implementation libs.patternlockview
// Markdown Preview
implementation libs.bundles.markwon
// Timber
implementation libs.timber
// Tests
testImplementation project(":qsferaTestUtil")
testImplementation libs.androidx.arch.core.testing
testImplementation libs.junit4
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.mockk
// Instrumented tests
androidTestImplementation project(":qsferaTestUtil")
androidTestImplementation libs.androidx.annotation
androidTestImplementation libs.androidx.arch.core.testing
androidTestImplementation libs.androidx.test.core
androidTestImplementation libs.androidx.test.ext.junit
androidTestImplementation libs.androidx.test.rules
androidTestImplementation libs.androidx.test.runner
androidTestImplementation libs.androidx.test.uiautomator
androidTestImplementation libs.bundles.espresso
androidTestImplementation libs.dexopener
androidTestImplementation(libs.mockk.android) { exclude module: "objenesis" }
// Kaspresso
androidTestImplementation libs.kaspresso
// Debug
debugImplementation libs.androidx.fragment.testing
debugImplementation libs.androidx.test.monitor
debugImplementation libs.stetho
// Detekt
detektPlugins libs.detekt.formatting
detektPlugins libs.detekt.libraries
}
configurations.all {
resolutionStrategy {
force "androidx.test:core:1.6.1"
force "androidx.test:core-ktx:1.6.1"
force "androidx.test:monitor:1.7.2"
force "androidx.test:runner:1.6.2"
force "androidx.test:rules:1.6.1"
force "androidx.test.espresso:espresso-core:3.6.1"
}
}
android {
compileSdkVersion sdkCompileVersion
defaultConfig {
minSdkVersion sdkMinVersion
targetSdkVersion sdkTargetVersion
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
// 2. If the caller (GitHub) provided a specific number, overwrite the versionCode.
if (project.hasProperty('versionCode')) {
versionCode project.property('versionCode').toInteger()
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
androidTest.java.srcDirs += "src/test-common/java"
test.java.srcDirs += "src/test-common/java"
androidTest {
java.srcDirs += ['src/integrationTest/java']
}
}
lint {
abortOnError false
htmlOutput file('../lint-app-report.html')
ignoreWarnings false
xmlReport false
}
signingConfigs {
release {
def releaseKeystore = envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')
if (releaseKeystore) {
storeFile file(releaseKeystore) // use an absolute path
storePassword envValue('QSFERA_RELEASE_KEYSTORE_PASSWORD', 'OC_RELEASE_KEYSTORE_PASSWORD')
keyAlias envValue('QSFERA_RELEASE_KEY_ALIAS', 'OC_RELEASE_KEY_ALIAS')
keyPassword envValue('QSFERA_RELEASE_KEY_PASSWORD', 'OC_RELEASE_KEY_PASSWORD')
}
}
}
buildTypes {
release {
if (envValue('QSFERA_RELEASE_KEYSTORE', 'OC_RELEASE_KEYSTORE')) {
signingConfig signingConfigs.release
}
}
debug {
applicationIdSuffix ".debug"
}
}
flavorDimensions "management"
productFlavors {
original {
dimension "management"
}
//mdm {
// dimension "management"
//}
qa {
dimension "management"
applicationIdSuffix ".qa"
// replace stuff from setup.xml
resValue "string", "app_name", "КуСфера QA"
resValue "string", "account_type", "eu.qsfera.qa"
resValue "string", "authority", "eu.qsfera.qa"
resValue "string", "document_provider_authority", "eu.qsfera.qa.documents"
resValue "string", "file_provider_authority", "eu.qsfera.qa.files"
resValue "string", "search_suggest_authority", "eu.qsfera.qa.search.users_and_groups"
resValue "string", "search_suggest_intent_action", "eu.qsfera.qa.search.users_and_groups.action.SHARE_WITH"
}
}
applicationVariants.all { variant ->
def appName = envValue('QSFERA_APP_NAME', 'OC_APP_NAME')
setOutputFileName(variant, appName, project)
}
testOptions {
packagingOptions {
jniLibs {
useLegacyPackaging = true
}
}
unitTests.returnDefaultValues = true
animationsDisabled = true
}
buildFeatures {
viewBinding true
buildConfig true
}
packagingOptions {
resources.excludes.add("META-INF/*")
}
namespace "eu.qsfera.android"
testNamespace "eu.qsfera.android.test"
}
// Updates output file names of a given variant to format
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
//
// QSFERA_BUILD_NUMBER is an environment variable read directly in this method. If undefined, it's not added.
// OC_BUILD_NUMBER is still accepted as a legacy fallback.
//
// @param variant Build variant instance which output file name will be updated.
// @param appName String to use as first part of the new file name. May be undefined, the original
// project.archivesBaseName property will be used instead.
// @param callerProject Caller project.
def setOutputFileName(variant, appName, callerProject) {
logger.info("Setting new name for output of variant $variant.name")
def originalFile = variant.outputs[0].outputFile
def originalName = originalFile.name
println "originalName is $originalName"
def newName = ""
if (appName) {
newName += appName
} else {
newName += "QSfera"
}
def versionName = "$variant.mergedFlavor.versionName"
if (variant.mergedFlavor.manifestPlaceholders.versionName != null) {
versionName = "$variant.mergedFlavor.manifestPlaceholders.versionName"
}
if (variant.buildType.manifestPlaceholders.versionName != null) {
versionName = "$variant.buildType.manifestPlaceholders.versionName"
}
newName += "_$versionName"
def buildNumber = envValue('QSFERA_BUILD_NUMBER', 'OC_BUILD_NUMBER')
if (buildNumber) {
newName += "_$buildNumber"
}
newName += originalName.substring(callerProject.archivesBaseName.length())
println "$variant.name: newName is $newName"
variant.outputs.all {
outputFileName = newName
}
}
static def getLatestGitHash() {
def process = "git rev-parse --short HEAD".execute()
return process.text.toString().trim()
}
static def getGitOriginRemote() {
def process = "git remote -v".execute()
def values = process.text.toString().trim().split("\\r\\n|\\n|\\r")
def found = values.find { it.startsWith("origin") && it.endsWith("(push)") }
return found.replace("origin", "").replace("(push)", "").replace(".git", "").trim()
}
tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach {
jvmTarget = "17"
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="MissingTranslation" severity="ignore" />
<issue id="MissingQuantity" severity="ignore" />
</lint>
@@ -0,0 +1,826 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
* @author Jorge Aguado Recio
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.authentication
import android.accounts.AccountManager
import android.accounts.AccountManager.KEY_ACCOUNT_NAME
import android.accounts.AccountManager.KEY_ACCOUNT_TYPE
import android.app.Activity.RESULT_OK
import android.app.Instrumentation
import android.content.Context
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.closeSoftKeyboard
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.Visibility
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.domain.exceptions.NoNetworkConnectionException
import eu.qsfera.android.domain.exceptions.QSferaVersionNotSupportedException
import eu.qsfera.android.domain.exceptions.ServerNotReachableException
import eu.qsfera.android.domain.exceptions.UnauthorizedException
import eu.qsfera.android.domain.server.model.ServerInfo
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.extensions.parseError
import eu.qsfera.android.presentation.authentication.ACTION_UPDATE_EXPIRED_TOKEN
import eu.qsfera.android.presentation.authentication.ACTION_UPDATE_TOKEN
import eu.qsfera.android.presentation.authentication.AuthenticationViewModel
import eu.qsfera.android.presentation.authentication.BASIC_TOKEN_TYPE
import eu.qsfera.android.presentation.authentication.EXTRA_ACCOUNT
import eu.qsfera.android.presentation.authentication.EXTRA_ACTION
import eu.qsfera.android.presentation.authentication.KEY_AUTH_TOKEN_TYPE
import eu.qsfera.android.presentation.authentication.LoginActivity
import eu.qsfera.android.presentation.authentication.OAUTH_TOKEN_TYPE
import eu.qsfera.android.presentation.authentication.oauth.OAuthViewModel
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.settings.SettingsActivity
import eu.qsfera.android.presentation.settings.SettingsViewModel
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_AUTH_TOKEN_TYPE
import eu.qsfera.android.testutil.OC_BASIC_PASSWORD
import eu.qsfera.android.testutil.OC_BASIC_USERNAME
import eu.qsfera.android.testutil.OC_INSECURE_SERVER_INFO_BASIC_AUTH
import eu.qsfera.android.testutil.OC_SECURE_SERVER_INFO_BASIC_AUTH
import eu.qsfera.android.testutil.OC_SECURE_SERVER_INFO_BEARER_AUTH
import eu.qsfera.android.utils.CONFIGURATION_SERVER_URL
import eu.qsfera.android.utils.CONFIGURATION_SERVER_URL_INPUT_VISIBILITY
import eu.qsfera.android.utils.NO_MDM_RESTRICTION_YET
import eu.qsfera.android.utils.matchers.assertVisibility
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.isEnabled
import eu.qsfera.android.utils.matchers.isFocusable
import eu.qsfera.android.utils.matchers.withText
import eu.qsfera.android.utils.mockIntentToComponent
import eu.qsfera.android.utils.replaceText
import eu.qsfera.android.utils.scrollAndClick
import eu.qsfera.android.utils.typeText
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class LoginActivityTest {
private lateinit var activityScenario: ActivityScenario<LoginActivity>
private lateinit var authenticationViewModel: AuthenticationViewModel
private lateinit var oauthViewModel: OAuthViewModel
private lateinit var settingsViewModel: SettingsViewModel
private lateinit var ocContextProvider: ContextProvider
private lateinit var mdmProvider: MdmProvider
private lateinit var context: Context
private lateinit var loginResultLiveData: MutableLiveData<Event<UIResult<String>>>
private lateinit var serverInfoLiveData: MutableLiveData<Event<UIResult<ServerInfo>>>
private lateinit var supportsOauth2LiveData: MutableLiveData<Event<UIResult<Boolean>>>
private lateinit var baseUrlLiveData: MutableLiveData<Event<UIResult<String>>>
private lateinit var accountDiscoveryLiveData: MutableLiveData<Event<UIResult<Unit>>>
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
authenticationViewModel = mockk(relaxed = true)
oauthViewModel = mockk(relaxed = true)
settingsViewModel = mockk(relaxUnitFun = true)
ocContextProvider = mockk(relaxed = true)
mdmProvider = mockk(relaxed = true)
val accountManager = mockk<AccountManager>(relaxed = true)
every { accountManager.getUserData(any(), any()) } returns null
every { accountManager.getPassword(any()) } returns null
mockkStatic(AccountManager::class)
every { AccountManager.get(any()) } returns accountManager
loginResultLiveData = MutableLiveData()
serverInfoLiveData = MutableLiveData()
supportsOauth2LiveData = MutableLiveData()
baseUrlLiveData = MutableLiveData()
accountDiscoveryLiveData = MutableLiveData()
every { authenticationViewModel.loginResult } returns loginResultLiveData
every { authenticationViewModel.serverInfo } returns serverInfoLiveData
every { authenticationViewModel.supportsOAuth2 } returns supportsOauth2LiveData
every { authenticationViewModel.baseUrl } returns baseUrlLiveData
every { authenticationViewModel.accountDiscovery } returns accountDiscoveryLiveData
every { settingsViewModel.isThereAttachedAccount() } returns false
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
authenticationViewModel
}
viewModel {
oauthViewModel
}
viewModel {
settingsViewModel
}
factory {
ocContextProvider
}
factory {
mdmProvider
}
}
)
}
}
@After
fun tearDown() {
unmockkAll()
}
private fun launchTest(
showServerUrlInput: Boolean = true,
serverUrl: String = "",
showLoginBackGroundImage: Boolean = true,
showWelcomeLink: Boolean = true,
accountType: String = "qsfera",
loginWelcomeText: String = "",
webfingerLookupServer: String = "",
intent: Intent? = null
) {
every { mdmProvider.getBrandingBoolean(CONFIGURATION_SERVER_URL_INPUT_VISIBILITY, R.bool.show_server_url_input) } returns showServerUrlInput
every { mdmProvider.getBrandingString(CONFIGURATION_SERVER_URL, R.string.server_url) } returns serverUrl
every { mdmProvider.getBrandingString(NO_MDM_RESTRICTION_YET, R.string.webfinger_lookup_server) } returns webfingerLookupServer
every { ocContextProvider.getBoolean(R.bool.use_login_background_image) } returns showLoginBackGroundImage
every { ocContextProvider.getBoolean(R.bool.show_welcome_link) } returns showWelcomeLink
every { ocContextProvider.getString(R.string.account_type) } returns accountType
every { ocContextProvider.getString(R.string.login_welcome_text) } returns loginWelcomeText
every { ocContextProvider.getString(R.string.app_name) } returns BRANDED_APP_NAME
activityScenario = if (intent == null) {
ActivityScenario.launch(LoginActivity::class.java)
} else {
ActivityScenario.launch(intent)
}
}
@Test
fun initialViewStatus_notBrandedOptions() {
launchTest()
assertViewsDisplayed()
assertWebfingerFlowDisplayed(webfingerEnabled = false)
}
@Test
fun initialViewStatus_brandedOptions_webfinger() {
launchTest(webfingerLookupServer = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
assertWebfingerFlowDisplayed(webfingerEnabled = true)
}
@Test
fun initialViewStatus_brandedOptions_serverInfoInSetup() {
launchTest(showServerUrlInput = false, serverUrl = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
assertViewsDisplayed(
showHostUrlFrame = false,
showHostUrlInput = false,
showCenteredRefreshButton = true,
showEmbeddedCheckServerButton = false
)
}
@Test
fun initialViewStatus_brandedOptions_serverInfoInSetup_connectionFails() {
launchTest(showServerUrlInput = false, serverUrl = OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
serverInfoLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
R.id.centeredRefreshButton.isDisplayed(true)
R.id.centeredRefreshButton.scrollAndClick()
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
R.id.centeredRefreshButton.isDisplayed(false)
}
@Test
fun initialViewStatus_brandedOptions_dontUseLoginBackgroundImage() {
launchTest(showLoginBackGroundImage = false)
assertViewsDisplayed(showLoginBackGroundImage = false)
}
@Test
fun initialViewStatus_brandedOptions_dontShowWelcomeLink() {
launchTest(showWelcomeLink = false)
assertViewsDisplayed(showWelcomeLink = false)
}
@Test
fun initialViewStatus_brandedOptions_customWelcomeText() {
launchTest(showWelcomeLink = true, loginWelcomeText = CUSTOM_WELCOME_TEXT)
assertViewsDisplayed(showWelcomeLink = true)
R.id.welcome_link.withText(CUSTOM_WELCOME_TEXT)
}
@Test
fun initialViewStatus_brandedOptions_defaultWelcomeText() {
launchTest(showWelcomeLink = true, loginWelcomeText = "")
assertViewsDisplayed(showWelcomeLink = true)
R.id.welcome_link.withText(String.format(ocContextProvider.getString(R.string.auth_register), BRANDED_APP_NAME))
}
@Test
fun checkServerInfo_clickButton_callGetServerInfo() {
launchTest()
R.id.hostUrlInput.typeText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
R.id.embeddedCheckServerButton.scrollAndClick()
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
}
@Test
fun checkServerInfo_clickLogo_callGetServerInfo() {
launchTest()
R.id.hostUrlInput.typeText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
R.id.thumbnail.scrollAndClick()
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl, true) }
}
@Test
fun checkServerInfo_isLoading_show() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Loading()))
with(R.id.server_status_text) {
isDisplayed(true)
withText(R.string.auth_testing_connection)
}
}
@Test
fun checkServerInfo_isSuccess_updateUrlInput() {
launchTest()
R.id.hostUrlInput.typeText("demo.qsfera.eu")
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
R.id.hostUrlInput.withText(SECURE_SERVER_INFO_BASIC.baseUrl)
}
@Test
fun checkServerInfo_isSuccess_Secure() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.auth_secure_connection)
}
}
@Test
fun checkServerInfo_isSuccess_NotSecure() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(INSECURE_SERVER_INFO_BASIC)))
onView(withText(R.string.insecure_http_url_title_dialog)).check(matches(isDisplayed()))
onView(withText(R.string.insecure_http_url_message_dialog)).check(matches(isDisplayed()))
onView(withText(R.string.insecure_http_url_continue_button)).inRoot(isDialog()).check(matches(isDisplayed())).perform(click())
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.auth_connection_established)
}
}
@Test
fun checkServerInfo_isSuccess_Basic() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
checkBasicFieldsVisibility(loginButtonShouldBeVisible = false)
}
@Test
fun checkServerInfo_isSuccess_Bearer() {
Intents.init()
launchTest()
avoidOpeningChromeCustomTab()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BEARER)))
checkBearerFieldsVisibility()
Intents.release()
}
@Test
fun checkServerInfo_isSuccess_basicModifyUrlInput() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
checkBasicFieldsVisibility()
R.id.account_username.typeText(OC_BASIC_USERNAME)
R.id.account_password.typeText(OC_BASIC_PASSWORD)
R.id.hostUrlInput.typeText("anything")
with(R.id.account_username) {
withText("")
assertVisibility(Visibility.GONE)
}
with(R.id.account_password) {
withText("")
assertVisibility(Visibility.GONE)
}
R.id.loginButton.assertVisibility(Visibility.GONE)
}
@Test
fun checkServerInfo_isSuccess_bearerModifyUrlInput() {
Intents.init()
launchTest()
avoidOpeningChromeCustomTab()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BEARER)))
checkBearerFieldsVisibility()
R.id.hostUrlInput.typeText("anything")
R.id.auth_status_text.assertVisibility(Visibility.GONE)
Intents.release()
}
@Test
fun checkServerInfo_isError_emptyUrl() {
launchTest()
R.id.hostUrlInput.typeText("")
R.id.embeddedCheckServerButton.scrollAndClick()
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.auth_can_not_auth_against_server)
}
verify(exactly = 0) { authenticationViewModel.getServerInfo(any()) }
}
@Test
fun checkServerInfo_isError_qsferaVersionNotSupported() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Error(QSferaVersionNotSupportedException())))
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.server_not_supported)
}
}
@Test
fun checkServerInfo_isError_noNetworkConnection() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.error_no_network_connection)
}
}
@Test
fun checkServerInfo_isError_otherExceptions() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Error(ServerNotReachableException())))
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(R.string.network_host_not_available)
}
}
@Ignore
@Test
fun loginBasic_callLoginBasic() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
R.id.account_username.typeText(OC_BASIC_USERNAME)
R.id.account_password.typeText(OC_BASIC_PASSWORD)
with(R.id.loginButton) {
isDisplayed(true)
scrollAndClick()
}
verify(exactly = 1) { authenticationViewModel.loginBasic(OC_BASIC_USERNAME, OC_BASIC_PASSWORD, null) }
}
@Ignore
@Test
fun loginBasic_callLoginBasic_trimUsername() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
R.id.account_username.typeText(" $OC_BASIC_USERNAME ")
R.id.account_password.typeText(OC_BASIC_PASSWORD)
with(R.id.loginButton) {
isDisplayed(true)
scrollAndClick()
}
verify(exactly = 1) { authenticationViewModel.loginBasic(OC_BASIC_USERNAME, OC_BASIC_PASSWORD, null) }
}
@Test
fun loginBasic_showOrHideFields() {
launchTest()
serverInfoLiveData.postValue(Event(UIResult.Success(SECURE_SERVER_INFO_BASIC)))
R.id.account_username.typeText(OC_BASIC_USERNAME)
R.id.loginButton.isDisplayed(false)
R.id.account_password.typeText(OC_BASIC_PASSWORD)
R.id.loginButton.isDisplayed(true)
R.id.account_username.replaceText("")
R.id.loginButton.isDisplayed(false)
}
@Test
fun login_isLoading() {
launchTest()
loginResultLiveData.postValue(Event(UIResult.Loading()))
with(R.id.auth_status_text) {
withText(R.string.auth_trying_to_login)
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
}
}
@Ignore
@Test
fun login_isSuccess_finishResultCode() {
launchTest()
loginResultLiveData.postValue(Event(UIResult.Success(data = "Account_name")))
accountDiscoveryLiveData.postValue(Event(UIResult.Success()))
assertEquals(activityScenario.result.resultCode, RESULT_OK)
val accountName: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_NAME)
val accountType: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_TYPE)
assertNotNull(accountName)
assertNotNull(accountType)
assertEquals("Account_name", accountName)
assertEquals("qsfera", accountType)
}
@Ignore
@Test
fun login_isSuccess_finishResultCodeBrandedAccountType() {
launchTest(accountType = "notQSfera")
loginResultLiveData.postValue(Event(UIResult.Success(data = "Account_name")))
accountDiscoveryLiveData.postValue(Event(UIResult.Success()))
assertEquals(activityScenario.result.resultCode, RESULT_OK)
val accountName: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_NAME)
val accountType: String? = activityScenario.result?.resultData?.extras?.getString(KEY_ACCOUNT_TYPE)
assertNotNull(accountName)
assertNotNull(accountType)
assertEquals("Account_name", accountName)
assertEquals("notQSfera", accountType)
}
@Test
fun login_isError_NoNetworkConnectionException() {
launchTest()
loginResultLiveData.postValue(Event(UIResult.Error(NoNetworkConnectionException())))
R.id.server_status_text.withText(R.string.error_no_network_connection)
checkBasicFieldsVisibility(fieldsShouldBeVisible = false)
}
@Test
fun login_isError_ServerNotReachableException() {
launchTest()
loginResultLiveData.postValue(Event(UIResult.Error(ServerNotReachableException())))
R.id.server_status_text.withText(R.string.error_no_network_connection)
checkBasicFieldsVisibility(fieldsShouldBeVisible = false)
}
@Test
fun login_isError_OtherException() {
launchTest()
val exception = UnauthorizedException()
loginResultLiveData.postValue(Event(UIResult.Error(exception)))
R.id.auth_status_text.withText(exception.parseError("", context.resources, true) as String)
}
@Test
fun intent_withSavedAccount_viewModelCalls() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
}
launchTest(intent = intentWithAccount)
verify(exactly = 1) { authenticationViewModel.supportsOAuth2(OC_ACCOUNT.name) }
verify(exactly = 1) { authenticationViewModel.getBaseUrl(OC_ACCOUNT.name) }
}
@Test
fun supportsOAuth_isSuccess_actionUpdateExpiredTokenOAuth() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
putExtra(KEY_AUTH_TOKEN_TYPE, OAUTH_TOKEN_TYPE)
}
launchTest(intent = intentWithAccount)
supportsOauth2LiveData.postValue(Event(UIResult.Success(true)))
with(R.id.instructions_message) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(context.getString(R.string.auth_expired_oauth_token_toast))
}
}
@Test
fun supportsOAuth_isSuccess_actionUpdateToken() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
putExtra(EXTRA_ACTION, ACTION_UPDATE_TOKEN)
putExtra(KEY_AUTH_TOKEN_TYPE, OC_AUTH_TOKEN_TYPE)
}
launchTest(intent = intentWithAccount)
supportsOauth2LiveData.postValue(Event(UIResult.Success(false)))
R.id.instructions_message.assertVisibility(Visibility.GONE)
}
@Test
fun supportsOAuth_isSuccess_actionUpdateExpiredTokenBasic() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
putExtra(KEY_AUTH_TOKEN_TYPE, BASIC_TOKEN_TYPE)
}
launchTest(intent = intentWithAccount)
supportsOauth2LiveData.postValue(Event(UIResult.Success(false)))
with(R.id.instructions_message) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
withText(context.getString(R.string.auth_expired_basic_auth_toast))
}
}
@Test
fun getBaseUrl_isSuccess_updatesBaseUrl() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
}
launchTest(intent = intentWithAccount)
baseUrlLiveData.postValue(Event(UIResult.Success(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)))
with(R.id.hostUrlInput) {
withText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
assertVisibility(Visibility.VISIBLE)
isDisplayed(true)
isEnabled(false)
isFocusable(false)
}
verify(exactly = 0) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl) }
}
@Test
fun getBaseUrlAndActionNotCreate_isSuccess_updatesBaseUrl() {
val intentWithAccount = Intent(context, LoginActivity::class.java).apply {
putExtra(EXTRA_ACCOUNT, OC_ACCOUNT)
putExtra(EXTRA_ACTION, ACTION_UPDATE_EXPIRED_TOKEN)
}
launchTest(intent = intentWithAccount)
baseUrlLiveData.postValue(Event(UIResult.Success(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)))
with(R.id.hostUrlInput) {
withText(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl)
assertVisibility(Visibility.VISIBLE)
isDisplayed(true)
isEnabled(false)
isFocusable(false)
}
verify(exactly = 1) { authenticationViewModel.getServerInfo(OC_SECURE_SERVER_INFO_BASIC_AUTH.baseUrl) }
}
@Test
fun settingsLink() {
Intents.init()
launchTest()
closeSoftKeyboard()
mockIntentToComponent(RESULT_OK, SettingsActivity::class.java.name)
onView(withId(R.id.settings_link)).perform(click())
intended(hasComponent(SettingsActivity::class.java.name))
Intents.release()
}
private fun avoidOpeningChromeCustomTab() {
Intents.intending(allOf(IntentMatchers.hasAction(Intent.ACTION_VIEW)))
.respondWith(Instrumentation.ActivityResult(RESULT_OK, null))
}
private fun checkBasicFieldsVisibility(
fieldsShouldBeVisible: Boolean = true,
loginButtonShouldBeVisible: Boolean = false
) {
val visibilityMatcherFields = if (fieldsShouldBeVisible) Visibility.VISIBLE else Visibility.GONE
val visibilityMatcherLoginButton = if (loginButtonShouldBeVisible) Visibility.VISIBLE else Visibility.GONE
with(R.id.account_username) {
isDisplayed(fieldsShouldBeVisible)
assertVisibility(visibilityMatcherFields)
withText("")
}
with(R.id.account_password) {
isDisplayed(fieldsShouldBeVisible)
assertVisibility(visibilityMatcherFields)
withText("")
}
R.id.loginButton.assertVisibility(visibilityMatcherLoginButton)
R.id.auth_status_text.assertVisibility(visibilityMatcherLoginButton)
}
private fun checkBearerFieldsVisibility() {
R.id.account_username.assertVisibility(Visibility.GONE)
R.id.account_password.assertVisibility(Visibility.GONE)
R.id.auth_status_text.assertVisibility(Visibility.GONE)
with(R.id.server_status_text) {
isDisplayed(true)
assertVisibility(Visibility.VISIBLE)
}
}
private fun assertWebfingerFlowDisplayed(
webfingerEnabled: Boolean,
) {
R.id.webfinger_layout.isDisplayed(webfingerEnabled)
R.id.webfinger_username.isDisplayed(webfingerEnabled)
R.id.webfinger_button.isDisplayed(webfingerEnabled)
}
private fun assertViewsDisplayed(
showLoginBackGroundImage: Boolean = true,
showThumbnail: Boolean = true,
showCenteredRefreshButton: Boolean = false,
showInstructionsMessage: Boolean = false,
showHostUrlFrame: Boolean = true,
showHostUrlInput: Boolean = true,
showEmbeddedCheckServerButton: Boolean = true,
showEmbeddedRefreshButton: Boolean = false,
showServerStatusText: Boolean = false,
showAccountUsername: Boolean = false,
showAccountPassword: Boolean = false,
showAuthStatus: Boolean = false,
showLoginButton: Boolean = false,
showWelcomeLink: Boolean = true
) {
R.id.login_background_image.isDisplayed(displayed = showLoginBackGroundImage)
R.id.thumbnail.isDisplayed(displayed = showThumbnail)
R.id.centeredRefreshButton.isDisplayed(displayed = showCenteredRefreshButton)
R.id.instructions_message.isDisplayed(displayed = showInstructionsMessage)
R.id.hostUrlFrame.isDisplayed(displayed = showHostUrlFrame)
R.id.hostUrlInput.isDisplayed(displayed = showHostUrlInput)
R.id.embeddedCheckServerButton.isDisplayed(displayed = showEmbeddedCheckServerButton)
R.id.embeddedRefreshButton.isDisplayed(displayed = showEmbeddedRefreshButton)
R.id.server_status_text.isDisplayed(displayed = showServerStatusText)
R.id.account_username_container.isDisplayed(displayed = showAccountUsername)
R.id.account_username.isDisplayed(displayed = showAccountUsername)
R.id.account_password_container.isDisplayed(displayed = showAccountPassword)
R.id.account_password.isDisplayed(displayed = showAccountPassword)
R.id.auth_status_text.isDisplayed(displayed = showAuthStatus)
R.id.loginButton.isDisplayed(displayed = showLoginButton)
R.id.welcome_link.isDisplayed(displayed = showWelcomeLink)
}
companion object {
val SECURE_SERVER_INFO_BASIC = OC_SECURE_SERVER_INFO_BASIC_AUTH
val INSECURE_SERVER_INFO_BASIC = OC_INSECURE_SERVER_INFO_BASIC_AUTH
val SECURE_SERVER_INFO_BEARER = OC_SECURE_SERVER_INFO_BEARER_AUTH
private const val CUSTOM_WELCOME_TEXT = "Welcome to this test"
private const val BRANDED_APP_NAME = "BrandedAppName"
}
}
@@ -0,0 +1,97 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.files
import android.os.Bundle
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragment
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.RootMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import eu.qsfera.android.R
import eu.qsfera.android.presentation.files.SortBottomSheetFragment
import eu.qsfera.android.presentation.files.SortOrder
import eu.qsfera.android.presentation.files.SortType
import eu.qsfera.android.utils.matchers.bsfItemWithIcon
import eu.qsfera.android.utils.matchers.bsfItemWithTitle
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
class SortBottomSheetFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SortBottomSheetFragment>
private val fragmentListener = mockk<SortBottomSheetFragment.SortDialogListener>()
@Before
fun setUp() {
val fragmentArgs = Bundle().apply {
putParcelable(SortBottomSheetFragment.ARG_SORT_TYPE, SortType.SORT_TYPE_BY_NAME)
putParcelable(SortBottomSheetFragment.ARG_SORT_ORDER, SortOrder.SORT_ORDER_ASCENDING)
}
fragmentScenario = launchFragment(fragmentArgs)
every { fragmentListener.onSortSelected(any()) } returns Unit
fragmentScenario.onFragment { it.sortDialogListener = fragmentListener }
}
@Test
fun test_initial_view() {
onView(withId(R.id.title))
.inRoot(RootMatchers.isDialog())
.check(matches(ViewMatchers.withText(R.string.actionbar_sort_title)))
.check(matches(ViewMatchers.hasTextColor(R.color.bottom_sheet_fragment_title_color)))
with(R.id.sort_by_name) {
bsfItemWithTitle(R.string.global_name, R.color.primary)
bsfItemWithIcon(R.drawable.ic_sort_by_name, R.color.primary)
}
with(R.id.sort_by_size) {
bsfItemWithTitle(R.string.global_size, R.color.bottom_sheet_fragment_item_color)
bsfItemWithIcon(R.drawable.ic_sort_by_size, R.color.bottom_sheet_fragment_item_color)
}
with(R.id.sort_by_date) {
bsfItemWithTitle(R.string.global_date, R.color.bottom_sheet_fragment_item_color)
bsfItemWithIcon(R.drawable.ic_sort_by_date, R.color.bottom_sheet_fragment_item_color)
}
}
@Test
fun test_sort_by_name_click() {
onView(withId(R.id.sort_by_name)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_NAME) }
}
@Test
fun test_sort_by_date_click() {
onView(withId(R.id.sort_by_date)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_DATE) }
}
@Test
fun test_sort_by_size_click() {
onView(withId(R.id.sort_by_size)).inRoot(RootMatchers.isDialog()).perform(ViewActions.click())
verify { fragmentListener.onSortSelected(SortType.SORT_TYPE_BY_SIZE) }
}
}
@@ -0,0 +1,216 @@
package eu.qsfera.android.files.details
import android.content.Context
import androidx.test.core.app.ActivityScenario.launch
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withId
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.OCFileWithSyncInfo
import eu.qsfera.android.presentation.files.details.FileDetailsFragment
import eu.qsfera.android.presentation.files.details.FileDetailsViewModel
import eu.qsfera.android.presentation.files.operations.FileOperationsViewModel
import eu.qsfera.android.sharing.shares.ui.TestShareFileActivity
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_FILE
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AVAILABLE_OFFLINE
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AND_SPACE
import eu.qsfera.android.testutil.OC_FILE_WITH_SYNC_INFO_AND_WITHOUT_PERSONAL_SPACE
import eu.qsfera.android.utils.DisplayUtils
import eu.qsfera.android.utils.matchers.assertVisibility
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withDrawable
import eu.qsfera.android.utils.matchers.withText
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
@Ignore
class FileDetailsFragmentTest {
private lateinit var fileDetailsViewModel: FileDetailsViewModel
private lateinit var fileOperationsViewModel: FileOperationsViewModel
private lateinit var context: Context
private var currentFile: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AND_SPACE)
private var currentFileWithoutPersonalSpace: MutableStateFlow<OCFileWithSyncInfo?> =
MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AND_WITHOUT_PERSONAL_SPACE)
private var currentFileSyncInfo: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO)
private var currentFileAvailableOffline: MutableStateFlow<OCFileWithSyncInfo?> = MutableStateFlow(OC_FILE_WITH_SYNC_INFO_AVAILABLE_OFFLINE)
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
fileDetailsViewModel = mockk(relaxed = true)
fileOperationsViewModel = mockk(relaxed = true)
every { fileDetailsViewModel.currentFile } returns currentFile
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
fileDetailsViewModel
}
viewModel {
fileOperationsViewModel
}
}
)
}
val fileDetailsFragment = FileDetailsFragment.newInstance(
OC_FILE,
OC_ACCOUNT,
syncFileAtOpen = false
)
launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(fileDetailsFragment)
}
}
@Test
fun display_visibility_of_detail_view_when_it_is_displayed() {
assertViewsDisplayed()
}
@Test
fun show_space_personal_when_it_has_value() {
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdSpace.withText(R.string.bottom_nav_personal)
R.id.fdSpaceLabel.withText(R.string.space_label)
onView(withId(R.id.fdIconSpace))
.check(matches(withDrawable(R.drawable.ic_spaces)))
}
@Test
fun hide_space_when_it_has_no_value() {
every { fileDetailsViewModel.currentFile } returns currentFileSyncInfo
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.GONE)
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.GONE)
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.GONE)
}
@Test
fun show_space_not_personal_when_it_has_value() {
every { fileDetailsViewModel.currentFile } returns currentFileWithoutPersonalSpace
R.id.fdSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdSpaceLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdIconSpace.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdSpace.withText(currentFileWithoutPersonalSpace.value?.space?.name.toString())
R.id.fdSpaceLabel.withText(R.string.space_label)
onView(withId(R.id.fdIconSpace))
.check(matches(withDrawable(R.drawable.ic_spaces)))
}
@Test
fun show_last_sync_when_it_has_value() {
currentFile.value?.file?.lastSyncDateForData = 1212121212212
R.id.fdLastSync.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdLastSyncLabel.assertVisibility(ViewMatchers.Visibility.VISIBLE)
R.id.fdLastSyncLabel.withText(R.string.filedetails_last_sync)
R.id.fdLastSync.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.lastSyncDateForData!!))
}
@Test
fun hide_last_sync_when_it_has_no_value() {
every { fileDetailsViewModel.currentFile } returns currentFile
R.id.fdLastSync.assertVisibility(ViewMatchers.Visibility.GONE)
R.id.fdLastSyncLabel.assertVisibility(ViewMatchers.Visibility.GONE)
}
@Test
fun verifyTests() {
R.id.fdCreatedLabel.withText(R.string.filedetails_created)
R.id.fdCreated.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.creationTimestamp!!))
R.id.fdModifiedLabel.withText(R.string.filedetails_modified)
R.id.fdModified.withText(DisplayUtils.unixTimeToHumanReadable(currentFile.value?.file?.modificationTimestamp!!))
R.id.fdPathLabel.withText(R.string.ssl_validator_label_L)
R.id.fdPath.withText(currentFile.value?.file?.getParentRemotePath()!!)
R.id.fdname.withText(currentFile.value?.file?.fileName!!)
}
@Test
fun badge_available_offline_in_image_is_not_viewed_when_file_does_not_change_state() {
every { fileDetailsViewModel.currentFile } returns currentFileAvailableOffline
R.id.badgeDetailFile.assertVisibility(ViewMatchers.Visibility.VISIBLE)
onView(withId(R.id.badgeDetailFile))
.check(matches(withDrawable(R.drawable.offline_available_pin)))
}
@Test
fun show_badge_isAvailableLocally_in_image_when_file_change_state() {
currentFile.value?.file?.etagInConflict = "error"
R.id.badgeDetailFile.assertVisibility(ViewMatchers.Visibility.VISIBLE)
onView(withId(R.id.badgeDetailFile))
.check(matches(withDrawable(R.drawable.error_pin)))
}
private fun assertViewsDisplayed(
showImage: Boolean = true,
showFdName: Boolean = true,
showFdProgressText: Boolean = false,
showFdProgressBar: Boolean = false,
showFdCancelBtn: Boolean = false,
showDivider: Boolean = true,
showDivider2: Boolean = true,
showFdTypeLabel: Boolean = true,
showFdType: Boolean = true,
showFdSizeLabel: Boolean = true,
showFdSize: Boolean = true,
showFdModifiedLabel: Boolean = true,
showFdModified: Boolean = true,
showFdCreatedLabel: Boolean = true,
showFdCreated: Boolean = true,
showDivider3: Boolean = true,
showFdPathLabel: Boolean = true,
showFdPath: Boolean = true
) {
R.id.fdImageDetailFile.isDisplayed(displayed = showImage)
R.id.fdname.isDisplayed(displayed = showFdName)
R.id.fdProgressText.isDisplayed(displayed = showFdProgressText)
R.id.fdProgressBar.isDisplayed(displayed = showFdProgressBar)
R.id.fdCancelBtn.isDisplayed(displayed = showFdCancelBtn)
R.id.divider.isDisplayed(displayed = showDivider)
R.id.fdTypeLabel.isDisplayed(displayed = showFdTypeLabel)
R.id.fdType.isDisplayed(displayed = showFdType)
R.id.fdSizeLabel.isDisplayed(displayed = showFdSizeLabel)
R.id.fdSize.isDisplayed(displayed = showFdSize)
R.id.divider2.isDisplayed(displayed = showDivider2)
R.id.fdModifiedLabel.isDisplayed(displayed = showFdModifiedLabel)
R.id.fdModified.isDisplayed(displayed = showFdModified)
R.id.fdCreatedLabel.isDisplayed(displayed = showFdCreatedLabel)
R.id.fdCreated.isDisplayed(displayed = showFdCreated)
R.id.divider3.isDisplayed(displayed = showDivider3)
R.id.fdPathLabel.isDisplayed(displayed = showFdPathLabel)
R.id.fdPath.isDisplayed(displayed = showFdPath)
}
}
@@ -0,0 +1,106 @@
/**
* qsfera Android client application
*
* @author Fernando Sanz Velasco
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.logging
import androidx.test.core.app.ActivityScenario
import eu.qsfera.android.R
import eu.qsfera.android.presentation.logging.LogsListActivity
import eu.qsfera.android.presentation.logging.LogListViewModel
import eu.qsfera.android.utils.matchers.assertChildCount
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withText
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import java.io.File
@Ignore
class LogsListActivityTest {
private lateinit var activityScenario: ActivityScenario<LogsListActivity>
private lateinit var logListViewModel: LogListViewModel
private fun launchTest(logs: List<File>) {
every { logListViewModel.getLogsFiles() } returns logs
activityScenario = ActivityScenario.launch(LogsListActivity::class.java)
}
@Before
fun setUp() {
logListViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
allowOverride(override = true)
modules(
module {
viewModel {
logListViewModel
}
}
)
}
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun test_visibility_toolbar() {
launchTest(logs = emptyList())
R.id.toolbar_activity_logs_list.isDisplayed(true)
}
@Test
fun test_isRecyclerViewEmpty_show_label() {
launchTest(logs = emptyList())
R.id.logs_list_empty.isDisplayed(true)
R.id.list_empty_dataset_title.withText(R.string.prefs_log_no_logs_list_view)
R.id.list_empty_dataset_sub_title.withText(R.string.prefs_log_empty_subtitle)
R.id.recyclerView_activity_logs_list.isDisplayed(false)
}
@Test
fun test_isRecyclerViewNotEmpty_hide_label() {
launchTest(logs = listOf(File("path")))
R.id.logs_list_empty.isDisplayed(false)
R.id.recyclerView_activity_logs_list.isDisplayed(true)
}
@Test
fun test_childCount() {
launchTest(logs = listOf(File("qsfera.2021-01.01.log"), File("qsfera.2021-01-02.log")))
R.id.recyclerView_activity_logs_list.assertChildCount(2)
}
}
@@ -0,0 +1,300 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings
import android.content.ClipboardManager
import android.content.Context
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.preference.Preference
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.BuildConfig
import eu.qsfera.android.R
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
import eu.qsfera.android.presentation.settings.privacypolicy.PrivacyPolicyActivity
import eu.qsfera.android.presentation.settings.SettingsFragment
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
import eu.qsfera.android.presentation.settings.SettingsViewModel
import eu.qsfera.android.utils.matchers.verifyPreference
import eu.qsfera.android.utils.releaseNotesList
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SettingsFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SettingsFragment>
private var subsectionSecurity: Preference? = null
private var subsectionLogging: Preference? = null
private var subsectionPictureUploads: Preference? = null
private var subsectionVideoUploads: Preference? = null
private var subsectionMore: Preference? = null
private var prefPrivacyPolicy: Preference? = null
private var subsectionWhatsNew: Preference? = null
private var prefAboutApp: Preference? = null
private lateinit var settingsViewModel: SettingsViewModel
private lateinit var moreViewModel: SettingsMoreViewModel
private lateinit var releaseNotesViewModel: ReleaseNotesViewModel
private lateinit var context: Context
private lateinit var version: String
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext
settingsViewModel = mockk(relaxed = true)
moreViewModel = mockk(relaxed = true)
releaseNotesViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
settingsViewModel
}
viewModel {
moreViewModel
}
viewModel {
releaseNotesViewModel
}
}
)
}
version = String.format(
context.getString(R.string.prefs_app_version_summary),
context.getString(R.string.app_name),
BuildConfig.BUILD_TYPE,
BuildConfig.VERSION_NAME,
BuildConfig.COMMIT_SHA1
)
Intents.init()
}
@After
fun tearDown() {
Intents.release()
unmockkAll()
}
private fun launchTest(
attachedAccount: Boolean,
moreSectionVisible: Boolean = true,
privacyPolicyEnabled: Boolean = true,
whatsNewSectionVisible: Boolean = true
) {
every { settingsViewModel.isThereAttachedAccount() } returns attachedAccount
every { moreViewModel.shouldMoreSectionBeVisible() } returns moreSectionVisible
every { moreViewModel.isPrivacyPolicyEnabled() } returns privacyPolicyEnabled
every { releaseNotesViewModel.shouldWhatsNewSectionBeVisible() } returns whatsNewSectionVisible
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
fragmentScenario.onFragment { fragment ->
subsectionSecurity = fragment.findPreference(SUBSECTION_SECURITY)
subsectionLogging = fragment.findPreference(SUBSECTION_LOGGING)
subsectionPictureUploads = fragment.findPreference(SUBSECTION_PICTURE_UPLOADS)
subsectionVideoUploads = fragment.findPreference(SUBSECTION_VIDEO_UPLOADS)
subsectionMore = fragment.findPreference(SUBSECTION_MORE)
prefPrivacyPolicy = fragment.findPreference(PREFERENCE_PRIVACY_POLICY)
subsectionWhatsNew = fragment.findPreference(SUBSECTION_WHATSNEW)
prefAboutApp = fragment.findPreference(PREFERENCE_ABOUT_APP)
}
}
@Test
fun settingsViewCommon() {
launchTest(attachedAccount = false)
subsectionSecurity?.verifyPreference(
keyPref = SUBSECTION_SECURITY,
titlePref = context.getString(R.string.prefs_subsection_security),
summaryPref = context.getString(R.string.prefs_subsection_security_summary),
visible = true,
enabled = true
)
subsectionLogging?.verifyPreference(
keyPref = SUBSECTION_LOGGING,
titlePref = context.getString(R.string.prefs_subsection_logging),
summaryPref = context.getString(R.string.prefs_subsection_logging_summary),
visible = true,
enabled = true
)
subsectionMore?.verifyPreference(
keyPref = SUBSECTION_MORE,
titlePref = context.getString(R.string.prefs_subsection_more),
summaryPref = context.getString(R.string.prefs_subsection_more_summary),
visible = true,
enabled = true
)
prefPrivacyPolicy?.verifyPreference(
keyPref = PREFERENCE_PRIVACY_POLICY,
titlePref = context.getString(R.string.prefs_privacy_policy),
visible = true,
enabled = true
)
subsectionWhatsNew?.verifyPreference(
keyPref = SUBSECTION_WHATSNEW,
titlePref = context.getString(R.string.prefs_subsection_whatsnew),
visible = true,
enabled = true
)
prefAboutApp?.verifyPreference(
keyPref = PREFERENCE_ABOUT_APP,
titlePref = context.getString(R.string.prefs_app_version),
summaryPref = version,
visible = true,
enabled = true
)
}
@Test
fun settingsViewNoAccountAttached() {
launchTest(attachedAccount = false)
subsectionPictureUploads?.verifyPreference(
keyPref = SUBSECTION_PICTURE_UPLOADS,
titlePref = context.getString(R.string.prefs_subsection_picture_uploads),
summaryPref = context.getString(R.string.prefs_subsection_picture_uploads_summary),
visible = false
)
subsectionVideoUploads?.verifyPreference(
keyPref = SUBSECTION_VIDEO_UPLOADS,
titlePref = context.getString(R.string.prefs_subsection_video_uploads),
summaryPref = context.getString(R.string.prefs_subsection_video_uploads_summary),
visible = false
)
}
@Test
fun settingsViewAccountAttached() {
launchTest(attachedAccount = true)
subsectionPictureUploads?.verifyPreference(
keyPref = SUBSECTION_PICTURE_UPLOADS,
titlePref = context.getString(R.string.prefs_subsection_picture_uploads),
summaryPref = context.getString(R.string.prefs_subsection_picture_uploads_summary),
visible = true,
enabled = true
)
subsectionVideoUploads?.verifyPreference(
keyPref = SUBSECTION_VIDEO_UPLOADS,
titlePref = context.getString(R.string.prefs_subsection_video_uploads),
summaryPref = context.getString(R.string.prefs_subsection_video_uploads_summary),
visible = true,
enabled = true
)
}
@Test
fun settingsMoreSectionHidden() {
launchTest(attachedAccount = false, moreSectionVisible = false)
subsectionMore?.verifyPreference(
keyPref = SUBSECTION_MORE,
titlePref = context.getString(R.string.prefs_subsection_more),
summaryPref = context.getString(R.string.prefs_subsection_more_summary),
visible = false
)
}
@Test
fun settingsWhatsNewSectionHidden() {
launchTest(attachedAccount = false, whatsNewSectionVisible = false)
subsectionWhatsNew?.verifyPreference(
keyPref = SUBSECTION_WHATSNEW,
titlePref = context.getString(R.string.prefs_subsection_whatsnew),
visible = false
)
}
@Test
fun privacyPolicyOpensPrivacyPolicyActivity() {
launchTest(attachedAccount = false)
onView(withText(R.string.prefs_privacy_policy)).perform(click())
Intents.intended(IntentMatchers.hasComponent(PrivacyPolicyActivity::class.java.name))
}
@Ignore("Flaky test")
@Test
fun whatsnewOpensReleaseNotesActivity() {
launchTest(attachedAccount = false)
every { releaseNotesViewModel.getReleaseNotes() } returns releaseNotesList
onView(withText(R.string.prefs_subsection_whatsnew)).perform(click())
Intents.intended(IntentMatchers.hasComponent(ReleaseNotesActivity::class.java.name))
}
@Test
fun clickOnAppVersion() {
launchTest(attachedAccount = false)
onView(withText(R.string.prefs_app_version)).perform(click())
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
onView(withText(R.string.clipboard_text_copied)).check(matches(isEnabled()))
assertEquals(version, clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context))
}
companion object {
private const val SUBSECTION_SECURITY = "security_subsection"
private const val SUBSECTION_LOGGING = "logging_subsection"
private const val SUBSECTION_PICTURE_UPLOADS = "picture_uploads_subsection"
private const val SUBSECTION_VIDEO_UPLOADS = "video_uploads_subsection"
private const val SUBSECTION_MORE = "more_subsection"
private const val PREFERENCE_PRIVACY_POLICY = "privacyPolicy"
private const val PREFERENCE_ABOUT_APP = "about_app"
private const val SUBSECTION_WHATSNEW = "whatsNew"
}
}
@@ -0,0 +1,111 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.advanced
import android.content.Context
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.preference.SwitchPreferenceCompat
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.R
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedFragment.Companion.PREF_SHOW_HIDDEN_FILES
import eu.qsfera.android.presentation.settings.advanced.SettingsAdvancedViewModel
import eu.qsfera.android.utils.matchers.verifyPreference
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SettingsAdvancedFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SettingsAdvancedFragment>
private var prefShowHiddenFiles: SwitchPreferenceCompat? = null
private lateinit var advancedViewModel: SettingsAdvancedViewModel
private lateinit var context: Context
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext
advancedViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
advancedViewModel
}
}
)
}
every { advancedViewModel.isHiddenFilesShown() } returns true
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
fragmentScenario.onFragment { fragment ->
prefShowHiddenFiles = fragment.findPreference(PREF_SHOW_HIDDEN_FILES)
}
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun advancedView() {
assertNotNull(prefShowHiddenFiles)
prefShowHiddenFiles?.verifyPreference(
keyPref = PREF_SHOW_HIDDEN_FILES,
titlePref = context.getString(R.string.prefs_show_hidden_files),
visible = true,
enabled = true
)
}
@Ignore
@Test
fun disableShowHiddenFiles() {
prefShowHiddenFiles?.isChecked = advancedViewModel.isHiddenFilesShown()
onView(withText(context.getString(R.string.prefs_show_hidden_files))).perform(click())
prefShowHiddenFiles?.isChecked?.let { assertFalse(it) }
}
}
@@ -0,0 +1,209 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.logs
import android.content.Context
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.preference.CheckBoxPreference
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.R
import eu.qsfera.android.presentation.logging.LogsListActivity
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment
import eu.qsfera.android.presentation.logging.LogListViewModel
import eu.qsfera.android.presentation.settings.logging.SettingsLogsViewModel
import eu.qsfera.android.utils.matchers.verifyPreference
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SettingsLogsFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SettingsLogsFragment>
private lateinit var prefEnableLogging: SwitchPreferenceCompat
private lateinit var prefHttpLogs: CheckBoxPreference
private lateinit var prefLogsListActivity: Preference
private lateinit var logsViewModel: SettingsLogsViewModel
private lateinit var logListViewModel: LogListViewModel
private lateinit var context: Context
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext
logsViewModel = mockk(relaxed = true)
logListViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
logsViewModel
}
viewModel {
logListViewModel
}
}
)
}
Intents.init()
}
@After
fun tearDown() {
Intents.release()
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
unmockkAll()
}
private fun launchTest(enabledLogging: Boolean) {
every { logsViewModel.isLoggingEnabled() } returns enabledLogging
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
fragmentScenario.onFragment { fragment ->
prefEnableLogging = fragment.findPreference(SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING)!!
prefHttpLogs = fragment.findPreference(SettingsLogsFragment.PREFERENCE_LOG_HTTP)!!
prefLogsListActivity = fragment.findPreference(SettingsLogsFragment.PREFERENCE_LOGS_LIST)!!
}
}
@Test
fun logsViewLoggingDisabled() {
launchTest(enabledLogging = false)
prefEnableLogging.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING,
titlePref = context.getString(R.string.prefs_enable_logging),
summaryPref = context.getString(R.string.prefs_enable_logging_summary),
visible = true,
enabled = true
)
prefHttpLogs.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_LOG_HTTP,
titlePref = context.getString(R.string.prefs_http_logs),
visible = true,
enabled = false
)
prefLogsListActivity.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_LOGS_LIST,
titlePref = context.getString(R.string.prefs_log_open_logs_list_view),
visible = true,
enabled = true,
)
}
@Test
fun logsViewLoggingEnabled() {
launchTest(enabledLogging = true)
prefEnableLogging.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_ENABLE_LOGGING,
titlePref = context.getString(R.string.prefs_enable_logging),
summaryPref = context.getString(R.string.prefs_enable_logging_summary),
visible = true,
enabled = true
)
prefHttpLogs.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_LOG_HTTP,
titlePref = context.getString(R.string.prefs_http_logs),
visible = true,
enabled = true
)
prefLogsListActivity.verifyPreference(
keyPref = SettingsLogsFragment.PREFERENCE_LOGS_LIST,
titlePref = context.getString(R.string.prefs_log_open_logs_list_view),
visible = true,
enabled = true,
)
}
@Ignore
@Test
fun enableLoggingMakesSettingsEnable() {
launchTest(enabledLogging = false)
onView(withText(R.string.prefs_enable_logging)).perform(click())
assertTrue(prefHttpLogs.isEnabled)
}
@Test
fun disableLoggingMakesSettingsDisable() {
launchTest(enabledLogging = false)
onView(withText(R.string.prefs_enable_logging)).perform(click())
onView(withText(R.string.prefs_enable_logging)).perform(click())
assertFalse(prefHttpLogs.isEnabled)
assertTrue(prefLogsListActivity.isEnabled)
}
@Test
fun checkHttpLogs() {
launchTest(enabledLogging = true)
onView(withText(R.string.prefs_http_logs)).perform(click())
assertTrue(prefHttpLogs.isChecked)
}
@Test
fun disableLoggingMakesHttpLogsNotChecked() {
launchTest(enabledLogging = false)
onView(withText(R.string.prefs_enable_logging)).perform(click())
onView(withText(R.string.prefs_http_logs)).perform(click())
onView(withText(R.string.prefs_enable_logging)).perform(click())
assertFalse(prefHttpLogs.isChecked)
}
@Test
fun loggerOpen() {
launchTest(enabledLogging = true)
onView(withText(R.string.prefs_log_open_logs_list_view)).perform(click())
intended(hasComponent(LogsListActivity::class.java.name))
}
}
@@ -0,0 +1,365 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.more
import android.content.Context
import android.content.Intent
import android.content.Intent.EXTRA_SUBJECT
import android.content.Intent.EXTRA_TEXT
import android.net.Uri
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.preference.Preference
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
import androidx.test.espresso.intent.matcher.IntentMatchers.hasFlag
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.BuildConfig
import eu.qsfera.android.R
import eu.qsfera.android.presentation.settings.more.SettingsMoreFragment
import eu.qsfera.android.presentation.settings.more.SettingsMoreViewModel
import eu.qsfera.android.utils.matchers.verifyPreference
import eu.qsfera.android.utils.mockIntent
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.hamcrest.Matchers.allOf
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SettingsMoreFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SettingsMoreFragment>
private var prefHelp: Preference? = null
private var prefSync: Preference? = null
private var prefAccessDocProvider: Preference? = null
private var prefRecommend: Preference? = null
private var prefFeedback: Preference? = null
private var prefImprint: Preference? = null
private lateinit var moreViewModel: SettingsMoreViewModel
private lateinit var context: Context
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext
moreViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
moreViewModel
}
}
)
}
Intents.init()
}
@After
fun tearDown() {
Intents.release()
unmockkAll()
}
private fun getPreference(key: String): Preference? {
var preference: Preference? = null
fragmentScenario.onFragment { fragment ->
preference = fragment.findPreference(key)
}
return preference
}
private fun launchTest(
helpEnabled: Boolean = true,
syncEnabled: Boolean = true,
docProviderAppEnabled: Boolean = true,
recommendEnabled: Boolean = true,
feedbackEnabled: Boolean = true,
imprintEnabled: Boolean = true
) {
every { moreViewModel.isHelpEnabled() } returns helpEnabled
every { moreViewModel.isSyncEnabled() } returns syncEnabled
every { moreViewModel.isDocProviderAppEnabled() } returns docProviderAppEnabled
every { moreViewModel.isRecommendEnabled() } returns recommendEnabled
every { moreViewModel.isFeedbackEnabled() } returns feedbackEnabled
every { moreViewModel.isImprintEnabled() } returns imprintEnabled
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
}
@Test
fun moreView() {
launchTest()
prefHelp = getPreference(PREFERENCE_HELP)
assertNotNull(prefHelp)
prefHelp?.verifyPreference(
keyPref = PREFERENCE_HELP,
titlePref = context.getString(R.string.prefs_help),
visible = true,
enabled = true
)
prefSync = getPreference(PREFERENCE_SYNC_CALENDAR_CONTACTS)
assertNotNull(prefSync)
prefSync?.verifyPreference(
keyPref = PREFERENCE_SYNC_CALENDAR_CONTACTS,
titlePref = context.getString(R.string.prefs_sync_calendar_contacts),
summaryPref = context.getString(R.string.prefs_sync_calendar_contacts_summary),
visible = true,
enabled = true
)
prefAccessDocProvider = getPreference(PREFERENCE_ACCESS_DOCUMENT_PROVIDER)
assertNotNull(prefAccessDocProvider)
prefAccessDocProvider?.verifyPreference(
keyPref = PREFERENCE_ACCESS_DOCUMENT_PROVIDER,
titlePref = context.getString(R.string.prefs_access_document_provider),
summaryPref = context.getString(R.string.prefs_access_document_provider_summary),
visible = true,
enabled = true
)
prefRecommend = getPreference(PREFERENCE_RECOMMEND)
assertNotNull(prefRecommend)
prefRecommend?.verifyPreference(
keyPref = PREFERENCE_RECOMMEND,
titlePref = context.getString(R.string.prefs_recommend),
visible = true,
enabled = true
)
prefFeedback = getPreference(PREFERENCE_FEEDBACK)
assertNotNull(prefFeedback)
prefFeedback?.verifyPreference(
keyPref = PREFERENCE_FEEDBACK,
titlePref = context.getString(R.string.prefs_send_feedback),
visible = true,
enabled = true
)
prefImprint = getPreference(PREFERENCE_IMPRINT)
assertNotNull(prefImprint)
prefImprint?.verifyPreference(
keyPref = PREFERENCE_IMPRINT,
titlePref = context.getString(R.string.prefs_imprint),
visible = true,
enabled = true
)
}
@Test
fun helpNotEnabledView() {
launchTest(helpEnabled = false)
prefHelp = getPreference(PREFERENCE_HELP)
assertNull(prefHelp)
}
@Test
fun syncNotEnabledView() {
launchTest(syncEnabled = false)
prefSync = getPreference(PREFERENCE_SYNC_CALENDAR_CONTACTS)
assertNull(prefSync)
}
@Test
fun accessDocumentProviderNotEnabledView() {
launchTest(docProviderAppEnabled = false)
prefAccessDocProvider = getPreference(PREFERENCE_ACCESS_DOCUMENT_PROVIDER)
assertNull(prefAccessDocProvider)
}
@Test
fun recommendNotEnabledView() {
launchTest(recommendEnabled = false)
prefRecommend = getPreference(PREFERENCE_RECOMMEND)
assertNull(prefRecommend)
}
@Test
fun feedbackNotEnabledView() {
launchTest(feedbackEnabled = false)
prefFeedback = getPreference(PREFERENCE_FEEDBACK)
assertNull(prefFeedback)
}
@Test
fun imprintNotEnabledView() {
launchTest(imprintEnabled = false)
prefImprint = getPreference(PREFERENCE_IMPRINT)
assertNull(prefImprint)
}
@Ignore
@Test
fun helpOpensNotEmptyUrl() {
every { moreViewModel.getHelpUrl() } returns context.getString(R.string.url_help)
launchTest()
mockIntent(action = Intent.ACTION_VIEW)
onView(withText(R.string.prefs_help)).perform(click())
intended(hasData(context.getString(R.string.url_help)))
}
@Test
fun syncOpensNotEmptyUrl() {
every { moreViewModel.getSyncUrl() } returns context.getString(R.string.url_sync_calendar_contacts)
launchTest()
mockIntent(action = Intent.ACTION_VIEW)
onView(withText(R.string.prefs_sync_calendar_contacts)).perform(click())
intended(hasData(context.getString(R.string.url_sync_calendar_contacts)))
}
@Test
fun accessDocumentProviderOpensNotEmptyUrl() {
every { moreViewModel.getDocProviderAppUrl() } returns context.getString(R.string.url_document_provider_app)
launchTest()
mockIntent(action = Intent.ACTION_VIEW)
onView(withText(R.string.prefs_access_document_provider)).perform(click())
intended(hasData(context.getString(R.string.url_document_provider_app)))
}
@Test
fun recommendOpensSender() {
launchTest()
mockIntent(action = Intent.ACTION_SENDTO)
onView(withText(R.string.prefs_recommend)).perform(click())
// Delay needed since depending on the performance of the device where tests are executed,
// sender can interfere with the subsequent tests
Thread.sleep(1000)
intended(
allOf(
hasAction(Intent.ACTION_SENDTO), hasExtra(
EXTRA_SUBJECT, String.format(
context.getString(R.string.recommend_subject),
context.getString(R.string.app_name)
)
),
hasExtra(
EXTRA_TEXT,
String.format(
context.getString(R.string.recommend_text),
context.getString(R.string.app_name),
context.getString(R.string.url_app_download)
)
),
hasFlag(Intent.FLAG_ACTIVITY_NEW_TASK)
)
)
}
@Test
fun feedbackOpensSenderIfFeedbackMailExists() {
launchTest()
every { moreViewModel.getFeedbackMail() } returns FEEDBACK_MAIL
mockIntent(action = Intent.ACTION_SENDTO)
onView(withText(R.string.prefs_send_feedback)).perform(click())
// Delay needed since depending on the performance of the device where tests are executed,
// sender can interfere with the subsequent tests
Thread.sleep(1000)
intended(
allOf(
hasAction(Intent.ACTION_SENDTO),
hasExtra(
EXTRA_SUBJECT,
"Android v" + BuildConfig.VERSION_NAME + " - " + context.getText(R.string.prefs_feedback)
),
hasData(Uri.parse(FEEDBACK_MAIL)),
hasFlag(Intent.FLAG_ACTIVITY_NEW_TASK)
)
)
}
@Test
fun feedbackOpensAlertDialogIfFeedbackMailIsEmpty() {
launchTest()
every { moreViewModel.getFeedbackMail() } returns ""
onView(withText(R.string.prefs_send_feedback)).perform(click())
onView(withText(R.string.drawer_feedback)).check(ViewAssertions.matches(isDisplayed()))
}
@Test
fun imprintOpensUrl() {
every { moreViewModel.getImprintUrl() } returns "https://qsfera.eu/mobile"
launchTest()
mockIntent(action = Intent.ACTION_VIEW)
onView(withText(R.string.prefs_imprint)).perform(click())
intended(hasData("https://qsfera.eu/mobile"))
}
companion object {
private const val PREFERENCE_HELP = "help"
private const val PREFERENCE_SYNC_CALENDAR_CONTACTS = "syncCalendarContacts"
private const val PREFERENCE_ACCESS_DOCUMENT_PROVIDER = "accessDocumentProvider"
private const val PREFERENCE_RECOMMEND = "recommend"
private const val PREFERENCE_FEEDBACK = "feedback"
private const val PREFERENCE_IMPRINT = "imprint"
private const val FEEDBACK_MAIL = "mailto:mail@qsfera.eu"
}
}
@@ -0,0 +1,363 @@
/*
* qsfera Android client application
*
* @author Jesus Recio (@jesmrec)
* @author Christian Schabesberger (@theScrabi)
* @author Juan Carlos Garrote Gascón (@JuancaG05)
* @author David Crespo Ríos (@davcres)
*
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.security
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
import eu.qsfera.android.presentation.security.passcode.PasscodeAction
import eu.qsfera.android.presentation.security.passcode.PasscodeType
import eu.qsfera.android.presentation.security.passcode.Status
import eu.qsfera.android.presentation.security.biometric.BiometricViewModel
import eu.qsfera.android.presentation.security.passcode.PassCodeViewModel
import eu.qsfera.android.testutil.security.OC_PASSCODE_4_DIGITS
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withChildCountAndId
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class PassCodeActivityTest {
private lateinit var activityScenario: ActivityScenario<PassCodeActivity>
private lateinit var context: Context
private lateinit var timeToUnlockLiveData: MutableLiveData<Event<String>>
private lateinit var finishTimeToUnlockLiveData: MutableLiveData<Event<Boolean>>
private lateinit var statusLiveData: MutableLiveData<Status>
private lateinit var passcodeLiveData: MutableLiveData<String>
private lateinit var passCodeViewModel: PassCodeViewModel
private lateinit var biometricViewModel: BiometricViewModel
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
passCodeViewModel = mockk(relaxed = true)
biometricViewModel = mockk(relaxed = true)
timeToUnlockLiveData = MutableLiveData()
finishTimeToUnlockLiveData = MutableLiveData()
statusLiveData = MutableLiveData()
passcodeLiveData = MutableLiveData()
stopKoin()
startKoin {
allowOverride(override = true)
context
modules(
module {
viewModel {
passCodeViewModel
}
viewModel {
biometricViewModel
}
}
)
}
every { passCodeViewModel.getPassCode() } returns OC_PASSCODE_4_DIGITS
every { passCodeViewModel.getNumberOfPassCodeDigits() } returns 4
every { passCodeViewModel.getNumberOfAttempts() } returns 0
every { passCodeViewModel.getTimeToUnlockLiveData } returns timeToUnlockLiveData
every { passCodeViewModel.getFinishedTimeToUnlockLiveData } returns finishTimeToUnlockLiveData
every { passCodeViewModel.status } returns statusLiveData
every { passCodeViewModel.passcode } returns passcodeLiveData
}
@After
fun tearDown() {
// Clean preferences
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
}
@Test
fun passcodeCheckNotLockedView() {
// Open Activity in passcode check mode
openPasscodeActivity(PassCodeActivity.ACTION_CHECK)
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_enter_pass_code)
}
R.id.explanation.isDisplayed(false)
// Check if required amount of input fields are actually displayed
with(R.id.layout_code) {
isDisplayed(true)
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
}
R.id.lock_time.isDisplayed(false)
R.id.numberKeyboard.isDisplayed(true)
R.id.key0.isDisplayed(true)
R.id.key1.isDisplayed(true)
R.id.key2.isDisplayed(true)
R.id.key3.isDisplayed(true)
R.id.key4.isDisplayed(true)
R.id.key5.isDisplayed(true)
R.id.key6.isDisplayed(true)
R.id.key7.isDisplayed(true)
R.id.key8.isDisplayed(true)
R.id.key9.isDisplayed(true)
R.id.backspaceBtn.isDisplayed(true)
R.id.biometricBtn.isDisplayed(false)
}
@Test
fun passcodeCheckLockedView() {
every { passCodeViewModel.getNumberOfAttempts() } returns 3
every { passCodeViewModel.getTimeToUnlockLeft() } returns 3000
timeToUnlockLiveData.postValue(Event("00:03"))
// Open Activity in passcode check mode
openPasscodeActivity(PassCodeActivity.ACTION_CHECK)
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_enter_pass_code)
}
R.id.explanation.isDisplayed(false)
// Check if required amount of input fields are actually displayed
with(R.id.layout_code) {
isDisplayed(true)
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
}
R.id.lock_time.isDisplayed(true)
}
@Test
fun passcodeView() {
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_configure_your_pass_code)
}
with(R.id.explanation) {
isDisplayed(true)
withText(R.string.pass_code_configure_your_pass_code_explanation)
}
// Check if required amount of input fields are actually displayed
with(R.id.layout_code) {
isDisplayed(true)
withChildCountAndId(passCodeViewModel.getNumberOfPassCodeDigits(), R.id.passCodeEditText)
}
R.id.lock_time.isDisplayed(false)
R.id.error.isDisplayed(false)
}
@Test
fun firstTry() {
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.NO_CONFIRM))
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_reenter_your_pass_code)
}
onView(withText(R.string.pass_code_configure_your_pass_code)).check(doesNotExist())
R.id.error.isDisplayed(false)
}
@Ignore
@Test
fun secondTryCorrect() {
every { biometricViewModel.isBiometricLockAvailable() } returns true
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
// Click dialog's enable option
onView(withText(R.string.common_yes)).perform(click())
// Checking that the result returned is OK
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
}
@Test
fun secondTryIncorrect() {
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.ERROR))
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_configure_your_pass_code)
}
with(R.id.explanation) {
isDisplayed(true)
withText(R.string.pass_code_configure_your_pass_code_explanation)
}
with(R.id.error) {
isDisplayed(true)
withText(R.string.pass_code_mismatch)
}
R.id.lock_time.isDisplayed(false)
}
@Test
fun deletePasscodeView() {
// Open Activity in passcode deletion mode
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_remove_your_pass_code)
}
R.id.explanation.isDisplayed(false)
R.id.error.isDisplayed(false)
R.id.lock_time.isDisplayed(false)
}
@Ignore
@Test
fun deletePasscodeCorrect() {
// Open Activity in passcode deletion mode
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
statusLiveData.postValue(Status(PasscodeAction.REMOVE, PasscodeType.OK))
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
}
@Test
fun deletePasscodeIncorrect() {
// Open Activity in passcode deletion mode
openPasscodeActivity(PassCodeActivity.ACTION_REMOVE)
statusLiveData.postValue(Status(PasscodeAction.REMOVE, PasscodeType.ERROR))
with(R.id.header) {
isDisplayed(true)
withText(R.string.pass_code_enter_pass_code)
}
with(R.id.error) {
isDisplayed(true)
withText(R.string.pass_code_wrong)
}
R.id.explanation.isDisplayed(false)
R.id.lock_time.isDisplayed(false)
}
@Ignore
@Test
fun checkEnableBiometricDialogIsVisible() {
every { biometricViewModel.isBiometricLockAvailable() } returns true
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
onView(withText(R.string.biometric_dialog_title)).check(matches(isDisplayed()))
onView(withText(R.string.common_yes)).check(matches(isDisplayed()))
onView(withText(R.string.common_no)).check(matches(isDisplayed()))
}
@Ignore
@Test
fun checkEnableBiometricDialogYesOption() {
every { biometricViewModel.isBiometricLockAvailable() } returns true
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
onView(withText(R.string.common_yes)).perform(click())
// Checking that the result returned is OK
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
}
@Ignore
@Test
fun checkEnableBiometricDialogNoOption() {
every { biometricViewModel.isBiometricLockAvailable() } returns true
// Open Activity in passcode creation mode
openPasscodeActivity(PassCodeActivity.ACTION_CREATE)
statusLiveData.postValue(Status(PasscodeAction.CREATE, PasscodeType.CONFIRM))
onView(withText(R.string.common_no)).perform(click())
// Checking that the result returned is OK
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
}
private fun openPasscodeActivity(mode: String) {
val intent = Intent(context, PassCodeActivity::class.java).apply {
action = mode
}
activityScenario = ActivityScenario.launch(intent)
}
}
@@ -0,0 +1,127 @@
/*
* qsfera Android client application
*
* @author Jesus Recio (@jesmrec)
* @author Juan Carlos Garrote Gascón (@JuancaG05)
*
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.security
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.presentation.security.pattern.PatternActivity
import eu.qsfera.android.presentation.security.pattern.PatternViewModel
import eu.qsfera.android.testutil.security.OC_PATTERN
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withText
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class PatternActivityTest {
private lateinit var activityScenario: ActivityScenario<PatternActivity>
private lateinit var context: Context
private lateinit var patternViewModel: PatternViewModel
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
patternViewModel = mockk(relaxUnitFun = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
patternViewModel
}
}
)
}
}
@After
fun tearDown() {
// Clean preferences
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
}
@Test
fun patternLockView() {
// Open Activity in pattern creation mode
openPatternActivity(PatternActivity.ACTION_REQUEST_WITH_RESULT)
with(R.id.header_pattern) {
isDisplayed(true)
withText(R.string.pattern_configure_pattern)
}
with(R.id.explanation_pattern) {
isDisplayed(true)
withText(R.string.pattern_configure_your_pattern_explanation)
}
R.id.pattern_lock_view.isDisplayed(true)
}
@Test
fun removePatternLock() {
// Save a pattern in Preferences
storePattern()
// Open Activity in pattern deletion mode
openPatternActivity(PatternActivity.ACTION_CHECK_WITH_RESULT)
with(R.id.header_pattern) {
isDisplayed(true)
withText(R.string.pattern_remove_pattern)
}
with(R.id.explanation_pattern) {
isDisplayed(true)
withText(R.string.pattern_no_longer_required)
}
}
private fun storePattern() {
val appPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit()
appPrefs.apply {
putString(PatternActivity.PREFERENCE_PATTERN, OC_PATTERN)
putBoolean(PatternActivity.PREFERENCE_SET_PATTERN, true)
apply()
}
}
private fun openPatternActivity(mode: String) {
val intent = Intent(context, PatternActivity::class.java).apply {
action = mode
}
activityScenario = ActivityScenario.launch(intent)
}
}
@@ -0,0 +1,499 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.settings.security
import android.app.Activity.RESULT_OK
import android.content.Context
import androidx.biometric.BiometricViewModel
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceManager
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.R
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
import eu.qsfera.android.presentation.security.biometric.BiometricManager
import eu.qsfera.android.presentation.security.PREFERENCE_LOCK_TIMEOUT
import eu.qsfera.android.presentation.security.pattern.PatternActivity
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment
import eu.qsfera.android.presentation.settings.security.SettingsSecurityFragment.Companion.PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER
import eu.qsfera.android.presentation.settings.security.SettingsSecurityViewModel
import eu.qsfera.android.testutil.security.OC_PATTERN
import eu.qsfera.android.utils.matchers.verifyPreference
import eu.qsfera.android.utils.mockIntent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import org.hamcrest.Matchers.not
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SettingsSecurityFragmentTest {
private lateinit var fragmentScenario: FragmentScenario<SettingsSecurityFragment>
private lateinit var prefPasscode: CheckBoxPreference
private lateinit var prefPattern: CheckBoxPreference
private var prefBiometric: CheckBoxPreference? = null
private lateinit var prefLockApplication: ListPreference
private lateinit var prefLockAccessDocumentProvider: CheckBoxPreference
private lateinit var prefTouchesWithOtherVisibleWindows: CheckBoxPreference
private lateinit var securityViewModel: SettingsSecurityViewModel
private lateinit var biometricViewModel: BiometricViewModel
private lateinit var context: Context
@Before
fun setUp() {
context = InstrumentationRegistry.getInstrumentation().targetContext
securityViewModel = mockk(relaxed = true)
biometricViewModel = mockk(relaxed = true)
mockkObject(BiometricManager)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
securityViewModel
}
}
)
}
every { securityViewModel.isSecurityEnforcedEnabled() } returns false
every { securityViewModel.getBiometricsState() } returns false
every { securityViewModel.isLockDelayEnforcedEnabled() } returns false
Intents.init()
}
@After
fun tearDown() {
Intents.release()
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
}
private fun launchTest(withBiometrics: Boolean = true) {
every { BiometricManager.isHardwareDetected() } returns withBiometrics
fragmentScenario = launchFragmentInContainer(themeResId = R.style.Theme_qsfera)
fragmentScenario.onFragment { fragment ->
prefPasscode = fragment.findPreference(PassCodeActivity.PREFERENCE_SET_PASSCODE)!!
prefPattern = fragment.findPreference(PatternActivity.PREFERENCE_SET_PATTERN)!!
prefBiometric = fragment.findPreference(BiometricActivity.PREFERENCE_SET_BIOMETRIC)
prefLockApplication = fragment.findPreference(PREFERENCE_LOCK_TIMEOUT)!!
prefLockAccessDocumentProvider = fragment.findPreference(PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER)!!
prefTouchesWithOtherVisibleWindows =
fragment.findPreference(SettingsSecurityFragment.PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS)!!
}
}
private fun checkCommonPreferences() {
prefPasscode.verifyPreference(
keyPref = PassCodeActivity.PREFERENCE_SET_PASSCODE,
titlePref = context.getString(R.string.prefs_passcode),
visible = true,
enabled = true
)
assertFalse(prefPasscode.isChecked)
prefPattern.verifyPreference(
keyPref = PatternActivity.PREFERENCE_SET_PATTERN,
titlePref = context.getString(R.string.prefs_pattern),
visible = true,
enabled = true
)
assertFalse(prefPattern.isChecked)
prefLockApplication.verifyPreference(
keyPref = PREFERENCE_LOCK_TIMEOUT,
titlePref = context.getString(R.string.prefs_lock_application),
visible = true,
enabled = false
)
prefLockAccessDocumentProvider.verifyPreference(
keyPref = PREFERENCE_LOCK_ACCESS_FROM_DOCUMENT_PROVIDER,
titlePref = context.getString(R.string.prefs_lock_access_from_document_provider),
summaryPref = context.getString(R.string.prefs_lock_access_from_document_provider_summary),
visible = true,
enabled = true
)
assertFalse(prefLockAccessDocumentProvider.isChecked)
prefTouchesWithOtherVisibleWindows.verifyPreference(
keyPref = SettingsSecurityFragment.PREFERENCE_TOUCHES_WITH_OTHER_VISIBLE_WINDOWS,
titlePref = context.getString(R.string.prefs_touches_with_other_visible_windows),
summaryPref = context.getString(R.string.prefs_touches_with_other_visible_windows_summary),
visible = true,
enabled = true
)
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
}
@Test
fun securityViewDeviceWithBiometrics() {
launchTest()
checkCommonPreferences()
assertNotNull(prefBiometric)
prefBiometric?.run {
verifyPreference(
keyPref = BiometricActivity.PREFERENCE_SET_BIOMETRIC,
titlePref = context.getString(R.string.prefs_biometric),
summaryPref = context.getString(R.string.prefs_biometric_summary),
visible = true,
enabled = false
)
assertFalse(isChecked)
}
}
@Test
fun securityViewDeviceWithNoBiometrics() {
launchTest(withBiometrics = false)
checkCommonPreferences()
assertNull(prefBiometric)
}
@Ignore
@Test
fun passcodeOpen() {
every { securityViewModel.isPatternSet() } returns false
launchTest()
mockIntent(RESULT_OK, PassCodeActivity.ACTION_CREATE)
onView(withText(R.string.prefs_passcode)).perform(click())
intended(hasComponent(PassCodeActivity::class.java.name))
}
@Test
fun patternOpen() {
every { securityViewModel.isPasscodeSet() } returns false
launchTest()
onView(withText(R.string.prefs_pattern)).perform(click())
intended(hasComponent(PatternActivity::class.java.name))
}
@Ignore
@Test
fun passcodeLockEnabledOk() {
every { securityViewModel.isPatternSet() } returns false
launchTest()
mockIntent(
action = PassCodeActivity.ACTION_CREATE
)
onView(withText(R.string.prefs_passcode)).perform(click())
assertTrue(prefPasscode.isChecked)
}
@Test
fun patternLockEnabledOk() {
every { securityViewModel.isPasscodeSet() } returns false
launchTest()
mockIntent(
extras = Pair(PatternActivity.PREFERENCE_PATTERN, OC_PATTERN),
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
)
onView(withText(R.string.prefs_pattern)).perform(click())
assertTrue(prefPattern.isChecked)
}
@Ignore
@Test
fun enablePasscodeEnablesBiometricLockAndLockApplication() {
launchTest()
firstEnablePasscode()
onView(withText(R.string.prefs_biometric)).check(matches(isEnabled()))
assertTrue(prefBiometric!!.isEnabled)
assertFalse(prefBiometric!!.isChecked)
assertTrue(prefLockApplication.isEnabled)
}
@Test
fun enablePatternEnablesBiometricLockAndLockApplication() {
launchTest()
firstEnablePattern()
onView(withText(R.string.prefs_biometric)).check(matches(isEnabled()))
assertTrue(prefBiometric!!.isEnabled)
assertFalse(prefBiometric!!.isChecked)
assertTrue(prefLockApplication.isEnabled)
}
@Ignore
@Test
fun onlyOneMethodEnabledPattern() {
every { securityViewModel.isPatternSet() } returns true
launchTest()
firstEnablePattern()
onView(withText(R.string.prefs_passcode)).perform(click())
onView(withText(R.string.pattern_already_set)).check(matches(isEnabled()))
}
@Test
fun onlyOneMethodEnabledPasscode() {
every { securityViewModel.isPasscodeSet() } returns true
launchTest()
firstEnablePasscode()
onView(withText(R.string.prefs_pattern)).perform(click())
onView(withText(R.string.passcode_already_set)).check(matches(isEnabled()))
}
@Test
fun disablePasscodeOk() {
launchTest()
firstEnablePasscode()
mockIntent(
action = PassCodeActivity.ACTION_REMOVE
)
onView(withText(R.string.prefs_passcode)).perform(click())
assertFalse(prefPasscode.isChecked)
onView(withText(R.string.prefs_biometric)).check(matches(not(isEnabled())))
assertFalse(prefBiometric!!.isEnabled)
assertFalse(prefBiometric!!.isChecked)
assertFalse(prefLockApplication.isEnabled)
}
@Test
fun disablePatternOk() {
launchTest()
firstEnablePattern()
mockIntent(
action = PatternActivity.ACTION_CHECK_WITH_RESULT
)
onView(withText(R.string.prefs_pattern)).perform(click())
assertFalse(prefPattern.isChecked)
onView(withText(R.string.prefs_biometric)).check(matches(not(isEnabled())))
assertFalse(prefBiometric!!.isEnabled)
assertFalse(prefBiometric!!.isChecked)
assertFalse(prefLockApplication.isEnabled)
}
@Ignore
@Test
fun enableBiometricLockWithPasscodeEnabled() {
every { BiometricManager.hasEnrolledBiometric() } returns true
launchTest()
firstEnablePasscode()
onView(withText(R.string.prefs_biometric)).perform(click())
assertTrue(prefBiometric!!.isChecked)
}
@Test
fun enableBiometricLockWithPatternEnabled() {
every { BiometricManager.hasEnrolledBiometric() } returns true
launchTest()
firstEnablePattern()
onView(withText(R.string.prefs_biometric)).perform(click())
assertTrue(prefBiometric!!.isChecked)
}
@Ignore
@Test
fun enableBiometricLockNoEnrolledBiometric() {
every { BiometricManager.hasEnrolledBiometric() } returns false
launchTest()
firstEnablePasscode()
onView(withText(R.string.prefs_biometric)).perform(click())
assertFalse(prefBiometric!!.isChecked)
onView(withText(R.string.biometric_not_enrolled)).check(matches(isEnabled()))
}
@Test
fun disableBiometricLock() {
every { BiometricManager.hasEnrolledBiometric() } returns true
launchTest()
firstEnablePasscode()
onView(withText(R.string.prefs_biometric)).perform(click())
onView(withText(R.string.prefs_biometric)).perform(click())
assertFalse(prefBiometric!!.isChecked)
}
@Test
fun lockAccessFromDocumentProviderEnable() {
launchTest()
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
assertTrue(prefLockAccessDocumentProvider.isChecked)
}
@Test
fun lockAccessFromDocumentProviderDisable() {
launchTest()
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
onView(withText(R.string.prefs_lock_access_from_document_provider)).perform(click())
assertFalse(prefLockAccessDocumentProvider.isChecked)
}
@Test
fun touchesDialog() {
launchTest()
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
onView(withText(R.string.confirmation_touches_with_other_windows_title)).check(matches(isDisplayed()))
onView(withText(R.string.confirmation_touches_with_other_windows_message)).check(matches(isDisplayed()))
}
@Test
fun touchesEnable() {
launchTest()
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
onView(withText(R.string.common_yes)).perform(click())
assertTrue(prefTouchesWithOtherVisibleWindows.isChecked)
}
@Test
fun touchesRefuse() {
launchTest()
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
onView(withText(R.string.common_no)).perform(click())
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
}
@Test
fun touchesDisable() {
launchTest()
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
onView(withText(R.string.common_yes)).perform(click())
onView(withText(R.string.prefs_touches_with_other_visible_windows)).perform(click())
assertFalse(prefTouchesWithOtherVisibleWindows.isChecked)
}
@Test
fun passcodeLockNotVisible() {
every { securityViewModel.isSecurityEnforcedEnabled() } returns true
launchTest()
assertFalse(prefPasscode.isVisible)
}
@Test
fun patternLockNotVisible() {
every { securityViewModel.isSecurityEnforcedEnabled() } returns true
launchTest()
assertFalse(prefPattern.isVisible)
}
@Test
fun passcodeLockVisible() {
launchTest()
assertTrue(prefPasscode.isVisible)
}
@Test
fun patternLockVisible() {
launchTest()
assertTrue(prefPattern.isVisible)
}
@Ignore
@Test
fun checkIfUserEnabledBiometricRecommendation() {
every { securityViewModel.getBiometricsState() } returns true
launchTest()
firstEnablePasscode()
assertTrue(prefBiometric!!.isChecked)
assertTrue(prefBiometric!!.isEnabled)
}
@Test
fun checkIfUserNotEnabledBiometricRecommendation() {
launchTest()
firstEnablePasscode()
assertFalse(prefBiometric!!.isChecked)
}
private fun firstEnablePasscode() {
every { securityViewModel.isPatternSet() } returns false
mockIntent(
action = PassCodeActivity.ACTION_CREATE
)
onView(withText(R.string.prefs_passcode)).perform(click())
}
private fun firstEnablePattern() {
every { securityViewModel.isPasscodeSet() } returns false
mockIntent(
action = PatternActivity.ACTION_REQUEST_WITH_RESULT
)
onView(withText(R.string.prefs_pattern)).perform(click())
}
}
@@ -0,0 +1,126 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.sharees.ui
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.domain.sharing.shares.model.ShareType
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.sharees.SearchShareesFragment
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.sharing.shares.ui.TestShareFileActivity
import eu.qsfera.android.testutil.OC_SHARE
import io.mockk.every
import io.mockk.mockkClass
import org.hamcrest.CoreMatchers
import org.junit.Before
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class SearchShareesFragmentTest {
private val shareViewModel = mockkClass(ShareViewModel::class, relaxed = true)
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
@Before
fun setUp() {
every { shareViewModel.shares } returns sharesLiveData
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
shareViewModel
}
}
)
}
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
val searchShareesFragment = SearchShareesFragment()
it.startFragment(searchShareesFragment)
}
}
@Test
fun showSearchBar() {
onView(withId(R.id.search_mag_icon)).check(matches(isDisplayed()))
onView(withId(R.id.search_plate)).check(matches(isDisplayed()))
}
@Test
fun showUserShares() {
sharesLiveData.postValue(
Event(
UIResult.Success(
listOf(
OC_SHARE.copy(sharedWithDisplayName = "Sheldon"),
OC_SHARE.copy(sharedWithDisplayName = "Penny")
)
)
)
)
onView(withText("Sheldon"))
.check(matches(isDisplayed()))
.check(matches(hasSibling(withId(R.id.unshareButton))))
.check(matches(hasSibling(withId(R.id.editShareButton))))
onView(withText("Penny")).check(matches(isDisplayed()))
}
@Test
fun showGroupShares() {
sharesLiveData.postValue(
Event(
UIResult.Success(
listOf(
OC_SHARE.copy(
shareType = ShareType.GROUP,
sharedWithDisplayName = "Friends"
)
)
)
)
)
onView(withText("Friends (group)"))
.check(matches(isDisplayed()))
.check(matches(hasSibling(withId(R.id.icon))))
onView(ViewMatchers.withTagValue(CoreMatchers.equalTo(R.drawable.ic_group))).check(matches(isDisplayed()))
}
}
@@ -0,0 +1,278 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.shares.ui
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import eu.qsfera.android.R
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.sharees.EditPrivateShareFragment
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_FILE
import eu.qsfera.android.testutil.OC_FOLDER
import eu.qsfera.android.testutil.OC_SHARE
import eu.qsfera.android.utils.Permissions
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.CoreMatchers.not
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class EditPrivateShareFragmentTest {
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
private val defaultSharedWithDisplayName = "user"
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
private val privateShareAsLiveData = MutableLiveData<Event<UIResult<OCShare>>>()
private lateinit var activityScenario: ActivityScenario<TestShareFileActivity>
@Before
fun setUp() {
every { shareViewModel.privateShare } returns privateShareAsLiveData
every { shareViewModel.isResharingAllowed() } returns true
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
shareViewModel
}
}
)
}
}
@Test
fun showDialogTitle() {
loadEditPrivateShareFragment()
onView(withId(R.id.editShareTitle))
.check(
matches(
withText(
targetContext.getString(R.string.share_with_edit_title, defaultSharedWithDisplayName)
)
)
)
}
@Test
fun closeDialog() {
loadEditPrivateShareFragment()
onView(withId(R.id.closeButton)).perform(click())
activityScenario.onActivity { Assert.assertNull(it.getTestFragment()) }
}
@Test
fun showToggles() {
loadEditPrivateShareFragment()
onView(withId(R.id.canEditSwitch)).check(matches(isDisplayed()))
onView(withId(R.id.canShareSwitch)).check(matches(isDisplayed()))
}
@Test
fun showFileShareWithNoPermissions() {
loadEditPrivateShareFragment()
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFileShareWithEditPermissions() {
loadEditPrivateShareFragment(permissions = Permissions.EDIT_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFileShareWithSharePermissions() {
loadEditPrivateShareFragment(permissions = Permissions.SHARE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isChecked()))
}
@Test
fun showFileShareWithAllPermissions() {
loadEditPrivateShareFragment(permissions = Permissions.ALL_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isChecked()))
}
@Test
fun showFolderShareWithCreatePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithCreateChangePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_CHANGE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithCreateDeletePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_DELETE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithCreateChangeDeletePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CREATE_CHANGE_DELETE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithChangePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CHANGE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithChangeDeletePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_CHANGE_DELETE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun showFolderShareWithDeletePermissions() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_DELETE_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isNotChecked()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
}
@Test
fun disableEditPermissionWithFile() {
loadEditPrivateShareFragment(permissions = Permissions.EDIT_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
onView(withId(R.id.canEditSwitch)).perform(click())
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked())) // "Can edit" changes
onView(withId(R.id.canEditCreateCheckBox)).check(matches(not(isDisplayed()))) // No suboptions
onView(withId(R.id.canEditChangeCheckBox)).check(matches(not(isDisplayed())))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(not(isDisplayed())))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked())) // "Can share" does not change
}
@Test
fun disableEditPermissionWithFolder() {
loadEditPrivateShareFragment(true, permissions = Permissions.EDIT_PERMISSIONS.value)
onView(withId(R.id.canEditSwitch)).check(matches(isChecked()))
onView(withId(R.id.canEditCreateCheckBox)).check(matches(isDisplayed())) // Suboptions appear
onView(withId(R.id.canEditChangeCheckBox)).check(matches(isDisplayed()))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(isDisplayed()))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked()))
onView(withId(R.id.canEditSwitch)).perform(click())
onView(withId(R.id.canEditSwitch)).check(matches(isNotChecked())) // "Can edit" changes
onView(withId(R.id.canEditCreateCheckBox)).check(matches(not(isDisplayed()))) // Suboptions hidden
onView(withId(R.id.canEditChangeCheckBox)).check(matches(not(isDisplayed())))
onView(withId(R.id.canEditDeleteCheckBox)).check(matches(not(isDisplayed())))
onView(withId(R.id.canShareSwitch)).check(matches(isNotChecked())) // "Can share" does not change
}
private fun loadEditPrivateShareFragment(
isFolder: Boolean = false,
permissions: Int = Permissions.READ_PERMISSIONS.value
) {
val shareToEdit = OC_SHARE.copy(
sharedWithDisplayName = defaultSharedWithDisplayName,
permissions = permissions
)
val sharedFile = if (isFolder) OC_FOLDER else OC_FILE
val editPrivateShareFragment = EditPrivateShareFragment.newInstance(
shareToEdit,
sharedFile,
OC_ACCOUNT
)
activityScenario = ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(editPrivateShareFragment)
}
privateShareAsLiveData.postValue(
Event(
UIResult.Success(
OC_SHARE.copy(
shareWith = "user",
sharedWithDisplayName = "User",
path = "/Videos",
isFolder = isFolder,
permissions = permissions
)
)
)
)
}
}
@@ -0,0 +1,493 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.shares.ui
import android.text.InputType.TYPE_CLASS_TEXT
import android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withInputType
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.domain.capabilities.model.CapabilityBooleanType
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.shares.PublicShareDialogFragment
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_CAPABILITY
import eu.qsfera.android.testutil.OC_FILE
import eu.qsfera.android.testutil.OC_FOLDER
import eu.qsfera.android.utils.DateUtils
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import java.text.SimpleDateFormat
import java.util.Date
class PublicShareCreationDialogFragmentTest {
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
private val publicShareCreationStatus = MutableLiveData<Event<UIResult<Unit>>>()
@Before
fun setUp() {
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
every { shareViewModel.publicShareCreationStatus } returns publicShareCreationStatus
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
capabilityViewModel
}
viewModel {
shareViewModel
}
}
)
}
}
@Test
fun showDialogTitle() {
loadPublicShareDialogFragment()
onView(withId(R.id.publicShareDialogTitle)).check(matches(withText(R.string.share_via_link_create_title)))
}
@Test
fun showMandatoryFields() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkNameSection)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkPasswordSection)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkExpirationSection)).check(matches(isDisplayed()))
}
@Test
fun showDialogButtons() {
loadPublicShareDialogFragment()
onView(withId(R.id.cancelButton)).check(matches(isDisplayed()))
onView(withId(R.id.saveButton)).check(matches(isDisplayed()))
}
@Test
fun showFolderAdditionalFields() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.0.1",
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(isDisplayed()))
}
@Test
fun showDefaultLinkName() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkNameValue)).check(matches(withText("DOC_12112018.jpg link")))
}
@Test
fun enablePasswordSwitch() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkPasswordSwitch)).perform(click())
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(isDisplayed()))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun checkPasswordNotVisible() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkPasswordSwitch)).perform(click())
onView(withId(R.id.shareViaLinkPasswordValue)).perform(typeText("supersecure"))
onView(withId(R.id.shareViaLinkPasswordValue)).check(
matches(
withInputType(
TYPE_CLASS_TEXT or TYPE_TEXT_VARIATION_PASSWORD
)
)
)
}
@Test
fun checkPasswordEnforced() {
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkPasswordLabel)).check(
matches(withText(R.string.share_via_link_password_enforced_label))
)
onView(withId(R.id.shareViaLinkPasswordSwitch))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
onView(withId(R.id.shareViaLinkPasswordValue))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun checkExpireDateEnforced() {
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
filesSharingPublicExpireDateEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkExpirationLabel))
.check(matches(withText(R.string.share_via_link_expiration_date_enforced_label)))
onView(withId(R.id.shareViaLinkExpirationSwitch))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
onView(withId(R.id.shareViaLinkExpirationExplanationLabel))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
}
@Test
fun checkExpireDateNotEnforced() {
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
filesSharingPublicExpireDateEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkExpirationLabel))
.check(matches(withText(R.string.share_via_link_expiration_date_label)))
onView(withId(R.id.shareViaLinkExpirationSwitch))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
onView(withId(R.id.shareViaLinkExpirationExplanationLabel))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
}
@Test
fun enableExpirationSwitch() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkExpirationSwitch)).perform(click())
onView(withId(android.R.id.button1)).perform(click())
onView(withId(R.id.shareViaLinkExpirationValue))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
//TODO: check the date form the picker
}
@Test
fun cancelExpirationSwitch() {
loadPublicShareDialogFragment()
onView(withId(R.id.shareViaLinkExpirationSwitch)).perform(click())
onView(withId(android.R.id.button2)).perform(click())
onView(withId(R.id.shareViaLinkExpirationValue))
.check(matches(withEffectiveVisibility(ViewMatchers.Visibility.INVISIBLE)))
}
@Test
fun showError() {
loadPublicShareDialogFragment()
onView(withId(R.id.saveButton)).perform(click())
publicShareCreationStatus.postValue(
Event(
UIResult.Error(
error = Throwable("It was not possible to share this file or folder")
)
)
)
onView(withId(R.id.public_link_error_message)).check(matches(isDisplayed()))
onView(withId(R.id.public_link_error_message)).check(
matches(
withText(R.string.share_link_file_error)
)
)
}
@Test
fun uploadPermissionsWithFolderDisplayed() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(isDisplayed()))
}
@Test
fun uploadPermissionsWithFolderNotDisplayed() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkEditPermissionGroup)).check(matches(not(isDisplayed())))
}
@Test
fun expirationDateDays() {
val daysToTest = 15
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicExpireDateDays = daysToTest
)
)
val formattedDate = SimpleDateFormat.getDateInstance().format(
DateUtils.addDaysToDate(
Date(),
daysToTest
)
)
onView(withId(R.id.shareViaLinkExpirationSwitch))
.check(matches(isEnabled()))
onView(withId(R.id.shareViaLinkExpirationValue))
.check(matches(withText(formattedDate)))
}
@Test
fun passwordNotEnforced() {
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_label)))
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
}
@Test
fun passwordEnforced() {
loadPublicShareDialogFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun passwordEnforcedReadOnlyFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(scrollTo())
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun passwordNotEnforcedReadOnlyFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.FALSE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionReadOnly)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_label)))
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
}
@Test
fun passwordEnforcedReadWriteFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedReadWrite = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun passwordNotEnforcedReadWriteFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedReadWrite = CapabilityBooleanType.FALSE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionReadAndWrite)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_label)))
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
}
@Test
fun passwordEnforcedUploadOnlyFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_enforced_label)))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
@Test
fun passwordNotEnforcedUploadOnlyFolders() {
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.FALSE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.FALSE
)
)
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(click())
onView(withId(R.id.shareViaLinkPasswordLabel))
.check(matches(withText(R.string.share_via_link_password_label)))
onView(withId(R.id.saveButton)).check(matches(isEnabled()))
}
@Test
fun passwordEnforcedClearErrorMessageIfSwitchesToNotEnforced() {
val commonError = "Common error"
//One permission with password enforced. Error is cleaned after switching permission
//to a non-forced one
loadPublicShareDialogFragment(
isFolder = true,
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicSupportsUploadOnly = CapabilityBooleanType.TRUE,
filesSharingPublicUpload = CapabilityBooleanType.TRUE,
filesSharingPublicPasswordEnforcedUploadOnly = CapabilityBooleanType.FALSE,
filesSharingPublicPasswordEnforcedReadOnly = CapabilityBooleanType.FALSE,
filesSharingPublicPasswordEnforced = CapabilityBooleanType.TRUE
)
)
onView(withId(R.id.saveButton)).perform(scrollTo())
onView(withId(R.id.saveButton)).perform(click())
publicShareCreationStatus.postValue(
Event(
UIResult.Error(
error = Throwable(commonError)
)
)
)
onView(withId(R.id.public_link_error_message)).perform(scrollTo())
onView(withText(commonError)).check(matches(isDisplayed()))
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).perform(scrollTo(), click())
onView(withText(commonError)).check(matches(not(isDisplayed())))
onView(withId(R.id.saveButton)).check(matches(not(isEnabled())))
}
private fun loadPublicShareDialogFragment(
isFolder: Boolean = false,
capabilities: OCCapability = OC_CAPABILITY
) {
val file = if (isFolder) OC_FOLDER else OC_FILE
val publicShareDialogFragment = PublicShareDialogFragment.newInstanceToCreate(
file,
OC_ACCOUNT,
"DOC_12112018.jpg link"
)
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(publicShareDialogFragment)
}
capabilitiesLiveData.postValue(
Event(UIResult.Success(capabilities))
)
}
}
@@ -0,0 +1,145 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.shares.ui
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withHint
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.sharing.shares.model.ShareType
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.lib.resources.shares.RemoteShare
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.shares.PublicShareDialogFragment
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_FILE
import eu.qsfera.android.testutil.OC_SHARE
import io.mockk.every
import io.mockk.mockk
import org.junit.Before
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.GregorianCalendar
import java.util.TimeZone
class PublicShareEditionDialogFragmentTest {
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
private val expirationDate = 1556575200000 // GMT: Monday, April 29, 2019 10:00:00 PM
@Before
fun setUp() {
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
capabilityViewModel
}
viewModel {
shareViewModel
}
}
)
}
val publicShareDialogFragment = PublicShareDialogFragment.newInstanceToUpdate(
OC_FILE,
OC_ACCOUNT,
OC_SHARE.copy(
shareType = ShareType.PUBLIC_LINK,
shareWith = "user",
name = "Docs link",
permissions = RemoteShare.CREATE_PERMISSION_FLAG,
expirationDate = expirationDate,
isFolder = true
)
)
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(publicShareDialogFragment)
}
}
@Test
fun showEditionDialogTitle() {
onView(withId(R.id.publicShareDialogTitle)).check(matches(withText(R.string.share_via_link_edit_title)))
}
@Test
fun checkLinkNameSet() {
onView(withText(R.string.share_via_link_name_label)).check(matches(ViewMatchers.isDisplayed()))
onView(withId(R.id.shareViaLinkNameValue)).check(matches(withText("Docs link")))
}
@Test
fun checkUploadOnly() {
onView(withId(R.id.shareViaLinkEditPermissionUploadFiles)).check(matches(isChecked()))
}
@Test
fun checkPasswordSet() {
onView(withId(R.id.shareViaLinkPasswordLabel)).check(matches(withText(R.string.share_via_link_password_label)))
onView(withId(R.id.shareViaLinkPasswordSwitch)).check(matches(withEffectiveVisibility(VISIBLE)))
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(withEffectiveVisibility(VISIBLE)))
onView(withId(R.id.shareViaLinkPasswordValue)).check(matches(withHint(R.string.share_via_link_default_password)))
}
@Test
fun checkExpirationDateSet() {
val calendar = GregorianCalendar()
calendar.timeInMillis = expirationDate
val formatter: DateFormat = SimpleDateFormat.getDateInstance()
formatter.timeZone = TimeZone.getDefault()
val time = formatter.format(calendar.time)
onView(withId(R.id.shareViaLinkExpirationLabel)).check(matches(withText(R.string.share_via_link_expiration_date_label)))
onView(withId(R.id.shareViaLinkExpirationSwitch)).check(matches(withEffectiveVisibility(VISIBLE)))
onView(withId(R.id.shareViaLinkExpirationValue)).check(matches(withEffectiveVisibility(VISIBLE)))
onView(withId(R.id.shareViaLinkExpirationValue)).check(matches(withText(time)))
}
}
@@ -0,0 +1,320 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.shares.ui
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.hasSibling
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withTagValue
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.domain.capabilities.model.CapabilityBooleanType
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.domain.sharing.shares.model.ShareType
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.ShareFileFragment
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_CAPABILITY
import eu.qsfera.android.testutil.OC_FILE
import eu.qsfera.android.testutil.OC_SHARE
import eu.qsfera.android.utils.matchers.assertVisibility
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withText
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.CoreMatchers
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
@Ignore
class ShareFileFragmentTest {
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
@Before
fun setUp() {
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
every { shareViewModel.shares } returns sharesLiveData
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
capabilityViewModel
}
viewModel {
shareViewModel
}
}
)
}
}
@Test
fun showHeader() {
loadShareFileFragment()
onView(withId(R.id.shareFileName)).check(matches(withText(OC_FILE.fileName)))
}
@Test
fun fileSizeVisible() {
loadShareFileFragment()
R.id.shareFileSize.isDisplayed(displayed = true)
}
@Test
fun showPrivateLink() {
loadShareFileFragment()
R.id.getPrivateLinkButton.isDisplayed(displayed = true)
}
@Test
fun hidePrivateLink() {
loadShareFileFragment(capabilities = OC_CAPABILITY.copy(filesPrivateLinks = CapabilityBooleanType.FALSE))
R.id.getPrivateLinkButton.isDisplayed(displayed = false)
}
/******************************************************************************************************
******************************************* PRIVATE SHARES *******************************************
******************************************************************************************************/
private var userSharesList = listOf(
OC_SHARE.copy(sharedWithDisplayName = "Batman"),
OC_SHARE.copy(sharedWithDisplayName = "Joker")
)
private var groupSharesList = listOf(
OC_SHARE.copy(
shareType = ShareType.GROUP,
sharedWithDisplayName = "Suicide Squad"
),
OC_SHARE.copy(
shareType = ShareType.GROUP,
sharedWithDisplayName = "Avengers"
)
)
@Test
fun showUsersAndGroupsSectionTitle() {
loadShareFileFragment(shares = userSharesList)
onView(withText(R.string.share_with_user_section_title)).check(matches(isDisplayed()))
}
@Test
fun showNoPrivateShares() {
loadShareFileFragment(shares = listOf())
onView(withText(R.string.share_no_users)).check(matches(isDisplayed()))
}
@Test
fun showUserShares() {
loadShareFileFragment(shares = userSharesList)
onView(withText("Batman")).check(matches(isDisplayed()))
onView(withText("Batman")).check(matches(hasSibling(withId(R.id.unshareButton))))
.check(matches(isDisplayed()))
onView(withText("Batman")).check(matches(hasSibling(withId(R.id.editShareButton))))
.check(matches(isDisplayed()))
onView(withText("Joker")).check(matches(isDisplayed()))
}
@Test
fun showGroupShares() {
loadShareFileFragment(shares = listOf(groupSharesList.first()))
onView(withText("Suicide Squad (group)")).check(matches(isDisplayed()))
onView(withText("Suicide Squad (group)")).check(matches(hasSibling(withId(R.id.icon))))
.check(matches(isDisplayed()))
onView(withTagValue(CoreMatchers.equalTo(R.drawable.ic_group))).check(matches(isDisplayed()))
}
/******************************************************************************************************
******************************************* PUBLIC SHARES ********************************************
******************************************************************************************************/
private var publicShareList = listOf(
OC_SHARE.copy(
shareType = ShareType.PUBLIC_LINK,
path = "/Photos/image.jpg",
isFolder = false,
name = "Image link",
shareLink = "http://server:port/s/1"
),
OC_SHARE.copy(
shareType = ShareType.PUBLIC_LINK,
path = "/Photos/image.jpg",
isFolder = false,
name = "Image link 2",
shareLink = "http://server:port/s/2"
),
OC_SHARE.copy(
shareType = ShareType.PUBLIC_LINK,
path = "/Photos/image.jpg",
isFolder = false,
name = "Image link 3",
shareLink = "http://server:port/s/3"
)
)
@Test
fun showNoPublicShares() {
loadShareFileFragment(shares = listOf())
onView(withText(R.string.share_no_public_links)).check(matches(isDisplayed()))
}
@Test
fun showPublicShares() {
loadShareFileFragment(shares = publicShareList)
onView(withText("Image link")).check(matches(isDisplayed()))
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.getPublicLinkButton))))
.check(matches(isDisplayed()))
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.deletePublicLinkButton))))
.check(matches(isDisplayed()))
onView(withText("Image link")).check(matches(hasSibling(withId(R.id.editPublicLinkButton))))
.check(matches(isDisplayed()))
onView(withText("Image link 2")).check(matches(isDisplayed()))
onView(withText("Image link 3")).check(matches(isDisplayed()))
}
@Test
fun showPublicSharesSharingEnabled() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(filesSharingPublicEnabled = CapabilityBooleanType.TRUE),
shares = publicShareList
)
onView(withText("Image link")).check(matches(isDisplayed()))
onView(withText("Image link 2")).check(matches(isDisplayed()))
onView(withText("Image link 3")).check(matches(isDisplayed()))
}
@Test
fun hidePublicSharesSharingDisabled() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(filesSharingPublicEnabled = CapabilityBooleanType.FALSE),
shares = publicShareList
)
R.id.shareViaLinkSection.assertVisibility(ViewMatchers.Visibility.GONE)
}
@Test
fun createPublicShareMultipleCapability() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicMultiple = CapabilityBooleanType.TRUE
),
shares = listOf(publicShareList[0])
)
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.VISIBLE)
}
@Test
fun cannotCreatePublicShareMultipleCapability() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "10.1.1",
filesSharingPublicMultiple = CapabilityBooleanType.FALSE
),
shares = listOf(publicShareList[0])
)
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.INVISIBLE)
}
@Test
fun cannotCreatePublicShareServerCapability() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(
versionString = "9.3.1"
),
shares = listOf(publicShareList[0])
)
R.id.addPublicLinkButton.assertVisibility(ViewMatchers.Visibility.INVISIBLE)
}
/******************************************************************************************************
*********************************************** COMMON ***********************************************
******************************************************************************************************/
@Test
fun hideSharesSharingApiDisabled() {
loadShareFileFragment(
capabilities = OC_CAPABILITY.copy(
filesSharingApiEnabled = CapabilityBooleanType.FALSE
)
)
R.id.shareWithUsersSection.assertVisibility(ViewMatchers.Visibility.GONE)
R.id.shareViaLinkSection.assertVisibility(ViewMatchers.Visibility.GONE)
}
@Test
fun showError() {
loadShareFileFragment(
sharesUIResult = UIResult.Error(
error = Throwable("It was not possible to retrieve the shares from the server")
)
)
com.google.android.material.R.id.snackbar_text.withText(R.string.get_shares_error)
}
private fun loadShareFileFragment(
capabilities: OCCapability = OC_CAPABILITY,
capabilitiesEvent: Event<UIResult<OCCapability>> = Event(UIResult.Success(capabilities)),
shares: List<OCShare> = listOf(OC_SHARE),
sharesUIResult: UIResult<List<OCShare>> = UIResult.Success(shares)
) {
val shareFileFragment = ShareFileFragment.newInstance(
OC_FILE,
OC_ACCOUNT
)
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(shareFileFragment)
}
capabilitiesLiveData.postValue(capabilitiesEvent)
sharesLiveData.postValue(Event(sharesUIResult))
}
}
@@ -0,0 +1,104 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Jesus Recio Rincon
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.sharing.shares.ui
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import eu.qsfera.android.R
import eu.qsfera.android.domain.capabilities.model.OCCapability
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.domain.utils.Event
import eu.qsfera.android.presentation.common.UIResult
import eu.qsfera.android.presentation.sharing.ShareFileFragment
import eu.qsfera.android.presentation.capabilities.CapabilityViewModel
import eu.qsfera.android.presentation.sharing.ShareViewModel
import eu.qsfera.android.testutil.OC_ACCOUNT
import eu.qsfera.android.testutil.OC_CAPABILITY
import eu.qsfera.android.testutil.OC_FOLDER
import eu.qsfera.android.testutil.OC_SHARE
import io.mockk.every
import io.mockk.mockk
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Test
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class ShareFolderFragmentTest {
private val capabilityViewModel = mockk<CapabilityViewModel>(relaxed = true)
private val capabilitiesLiveData = MutableLiveData<Event<UIResult<OCCapability>>>()
private val shareViewModel = mockk<ShareViewModel>(relaxed = true)
private val sharesLiveData = MutableLiveData<Event<UIResult<List<OCShare>>>>()
@Before
fun setUp() {
every { capabilityViewModel.capabilities } returns capabilitiesLiveData
every { shareViewModel.shares } returns sharesLiveData
stopKoin()
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
allowOverride(override = true)
modules(
module {
viewModel {
capabilityViewModel
}
viewModel {
shareViewModel
}
}
)
}
val shareFileFragment = ShareFileFragment.newInstance(
OC_FOLDER.copy(privateLink = null),
OC_ACCOUNT
)
ActivityScenario.launch(TestShareFileActivity::class.java).onActivity {
it.startFragment(shareFileFragment)
}
capabilitiesLiveData.postValue(Event(UIResult.Success(OC_CAPABILITY)))
sharesLiveData.postValue(Event(UIResult.Success(listOf(OC_SHARE))))
}
@Test
fun folderSizeVisible() {
onView(withId(R.id.shareFileSize)).check(matches(not(isDisplayed())))
}
@Test
fun hidePrivateLink() {
onView(withId(R.id.getPrivateLinkButton)).check(matches(not(isDisplayed())))
}
}
@@ -0,0 +1,87 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package eu.qsfera.android.sharing.shares.ui
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import eu.qsfera.android.R
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.sharing.shares.model.OCShare
import eu.qsfera.android.presentation.sharing.ShareFragmentListener
import eu.qsfera.android.services.OperationsService
import eu.qsfera.android.testing.SingleFragmentActivity
import eu.qsfera.android.ui.fragment.FileFragment.ContainerActivity
import eu.qsfera.android.ui.helpers.FileOperationsHelper
class TestShareFileActivity : SingleFragmentActivity(), ShareFragmentListener, ContainerActivity {
fun startFragment(fragment: Fragment) {
supportFragmentManager.commit(allowStateLoss = true) {
add(R.id.container, fragment, TEST_FRAGMENT_TAG)
}
}
fun getTestFragment(): Fragment? = supportFragmentManager.findFragmentByTag(TEST_FRAGMENT_TAG)
override fun copyOrSendPrivateLink(file: OCFile) {
}
override fun deleteShare(remoteId: String) {
}
override fun showLoading() {
}
override fun dismissLoading() {
}
override fun showAddPublicShare(defaultLinkName: String) {
}
override fun showEditPublicShare(share: OCShare) {
}
override fun showRemoveShare(share: OCShare) {
}
override fun copyOrSendPublicLink(share: OCShare) {
}
override fun showSearchUsersAndGroups() {
}
override fun showEditPrivateShare(share: OCShare) {
}
companion object {
private const val TEST_FRAGMENT_TAG = "TEST FRAGMENT"
}
override fun getOperationsServiceBinder(): OperationsService.OperationsServiceBinder {
TODO("Not yet implemented")
}
override fun getFileOperationsHelper(): FileOperationsHelper {
TODO("Not yet implemented")
}
override fun showDetails(file: OCFile?) {
}
}
@@ -0,0 +1,117 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.ui.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import eu.qsfera.android.R
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesViewModel
import eu.qsfera.android.utils.click
import eu.qsfera.android.utils.matchers.assertChildCount
import eu.qsfera.android.utils.matchers.isDisplayed
import eu.qsfera.android.utils.matchers.withText
import eu.qsfera.android.utils.releaseNotesList
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
class ReleaseNotesActivityTest {
private lateinit var activityScenario: ActivityScenario<ReleaseNotesActivity>
private lateinit var context: Context
private lateinit var releaseNotesViewModel: ReleaseNotesViewModel
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
releaseNotesViewModel = mockk(relaxed = true)
stopKoin()
startKoin {
context
allowOverride(override = true)
modules(
module {
viewModel {
releaseNotesViewModel
}
}
)
}
every { releaseNotesViewModel.getReleaseNotes() } returns releaseNotesList
val intent = Intent(context, ReleaseNotesActivity::class.java)
activityScenario = ActivityScenario.launch(intent)
}
@Test
fun releaseNotesView() {
val header = String.format(
context.getString(R.string.release_notes_header),
context.getString(R.string.app_name)
)
val footer = String.format(
context.getString(R.string.release_notes_footer),
context.getString(R.string.app_name)
)
with(R.id.txtHeader) {
isDisplayed(true)
withText(header)
}
R.id.releaseNotes.isDisplayed(true)
with(R.id.txtFooter) {
isDisplayed(true)
withText(footer)
}
R.id.btnProceed.isDisplayed(true)
}
@Ignore
@Test
fun releaseNotesProceedButton() {
R.id.btnProceed.click()
assertEquals(activityScenario.result.resultCode, Activity.RESULT_OK)
}
@Ignore
@Test
fun test_childCount() {
R.id.releaseNotes.assertChildCount(3)
}
}
@@ -0,0 +1,42 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.scrollTo
import androidx.test.espresso.matcher.ViewMatchers.withId
fun Int.typeText(text: String) {
onView(withId(this)).perform(scrollTo(), ViewActions.typeText(text))
}
fun Int.replaceText(text: String) {
onView(withId(this)).perform(scrollTo(), ViewActions.replaceText(text))
}
fun Int.scrollAndClick() {
onView(withId(this)).perform(scrollTo(), ViewActions.click())
}
fun Int.click() {
onView(withId(this)).perform(ViewActions.click())
}
@@ -0,0 +1,58 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import androidx.test.espresso.intent.Intents.intending
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
fun mockIntent(
extras: Pair<String, String>,
resultCode: Int = Activity.RESULT_OK,
action: String
) {
val result = Intent()
result.putExtra(extras.first, extras.second)
val intentResult = Instrumentation.ActivityResult(resultCode, result)
intending(hasAction(action)).respondWith(intentResult)
}
@JvmName("mockIntentNoExtras")
fun mockIntent(
resultCode: Int = Activity.RESULT_OK,
action: String
) {
val result = Intent()
val intentResult = Instrumentation.ActivityResult(resultCode, result)
intending(hasAction(action)).respondWith(intentResult)
}
fun mockIntentToComponent(
resultCode: Int = Activity.RESULT_OK,
packageName: String
) {
val result = Intent()
val intentResult = Instrumentation.ActivityResult(resultCode, result)
intending(hasComponent(packageName)).respondWith(intentResult)
}
@@ -0,0 +1,38 @@
/**
* qsfera Android client application
*
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
import android.app.Application
import android.content.Context
import android.os.Build
import androidx.test.runner.AndroidJUnitRunner
import com.github.tmurakami.dexopener.DexOpener
/**
* We need to use DexOpener for executing instrumented tests on <P Android devices,
* as Mockk documentation suggests https://mockk.io/ANDROID.html
*/
class OCTestAndroidJUnitRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader, className: String, context: Context): Application {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
DexOpener.install(this)
}
return super.newApplication(cl, className, context)
}
}
@@ -0,0 +1,36 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
enum class Permissions(val value: Int) {
READ_PERMISSIONS(1),
EDIT_PERMISSIONS(3),
SHARE_PERMISSIONS(17),
ALL_PERMISSIONS(19),
// FOLDERS
EDIT_CREATE_PERMISSIONS(5),
EDIT_CREATE_CHANGE_PERMISSIONS(7),
EDIT_CREATE_DELETE_PERMISSIONS(13),
EDIT_CREATE_CHANGE_DELETE_PERMISSIONS(15),
EDIT_CHANGE_PERMISSIONS(3),
EDIT_CHANGE_DELETE_PERMISSIONS(11),
EDIT_DELETE_PERMISSIONS(9),
}
@@ -0,0 +1,42 @@
/**
* qsfera Android client application
*
* @author David Crespo Ríos
* Copyright (C) 2022 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
import eu.qsfera.android.R
import eu.qsfera.android.presentation.releasenotes.ReleaseNote
import eu.qsfera.android.presentation.releasenotes.ReleaseNoteType
val releaseNotesList = listOf(
ReleaseNote(
title = R.string.release_notes_header,
subtitle = R.string.release_notes_footer,
type = ReleaseNoteType.BUGFIX
),
ReleaseNote(
title = R.string.release_notes_header,
subtitle = R.string.release_notes_footer,
type = ReleaseNoteType.BUGFIX
),
ReleaseNote(
title = R.string.release_notes_header,
subtitle = R.string.release_notes_footer,
type = ReleaseNoteType.ENHANCEMENT
)
)
@@ -0,0 +1,77 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.test.espresso.Espresso
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.BoundedMatcher
import androidx.test.espresso.matcher.RootMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.withText
import eu.qsfera.android.R
import eu.qsfera.android.presentation.common.BottomSheetFragmentItemView
import org.hamcrest.Description
import org.hamcrest.Matcher
fun Int.bsfItemWithTitle(@StringRes title: Int, @ColorRes tintColor: Int?) {
Espresso.onView(ViewMatchers.withId(this)).inRoot(RootMatchers.isDialog())
.check(ViewAssertions.matches(withTitle(title, tintColor)))
}
fun Int.bsfItemWithIcon(@DrawableRes drawable: Int, @ColorRes tintColor: Int?) {
Espresso.onView(ViewMatchers.withId(this)).inRoot(RootMatchers.isDialog())
.check(ViewAssertions.matches(withIcon(drawable, tintColor)))
}
private fun withTitle(@StringRes title: Int, @ColorRes tintColor: Int?): Matcher<View> =
object : BoundedMatcher<View, BottomSheetFragmentItemView>(BottomSheetFragmentItemView::class.java) {
override fun describeTo(description: Description) {
description.appendText("BottomSheetFragmentItemView with text: $title")
tintColor?.let { description.appendText(" and tint color id: $tintColor") }
}
override fun matchesSafely(item: BottomSheetFragmentItemView): Boolean {
val itemTitleView = item.findViewById<TextView>(R.id.item_title)
val textMatches = withText(title).matches(itemTitleView)
val textColorMatches = tintColor?.let { withTextColor(tintColor).matches(itemTitleView) } ?: true
return textMatches && textColorMatches
}
}
private fun withIcon(@DrawableRes drawable: Int, @ColorRes tintColor: Int?): Matcher<View> =
object : BoundedMatcher<View, BottomSheetFragmentItemView>(BottomSheetFragmentItemView::class.java) {
override fun describeTo(description: Description) {
description.appendText("BottomSheetFragmentItemView with icon: $drawable")
tintColor?.let { description.appendText(" and tint color id: $tintColor") }
}
override fun matchesSafely(item: BottomSheetFragmentItemView): Boolean {
val itemIconView = item.findViewById<ImageView>(R.id.item_icon)
return withDrawable(drawable, tintColor).matches(itemIconView)
}
}
@@ -0,0 +1,62 @@
/**
* qsfera Android client application
*
* @author Christian Schabesberger
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import android.view.View
import android.view.ViewGroup
import androidx.test.espresso.matcher.BoundedMatcher
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.hamcrest.Description
fun withChildViewCount(count: Int, childMatcher: Matcher<View>): Matcher<View> {
return object : BoundedMatcher<View, ViewGroup>(ViewGroup::class.java) {
override fun matchesSafely(viewGroup: ViewGroup): Boolean {
var matchCount = 0
for (i in 0 until viewGroup.childCount) {
if (childMatcher.matches(viewGroup.getChildAt(i))) {
matchCount++
}
}
return matchCount == count
}
override fun describeTo(description: Description?) {
description?.appendText("ViewGroup with child-count=$count and")
childMatcher.describeTo(description)
}
}
}
fun nthChildOf(parentMatcher: Matcher<View>, childPosition: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun matchesSafely(view: View): Boolean {
if (view.parent !is ViewGroup) {
return parentMatcher.matches(view.parent)
}
val group = view.parent as ViewGroup
return parentMatcher.matches(view.parent) && group.getChildAt(childPosition) == view
}
override fun describeTo(description: Description) {
description.appendText("with $childPosition child view of type parentMatcher")
}
}
}
@@ -0,0 +1,63 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.PorterDuff
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.ImageView
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import org.hamcrest.Description
import org.hamcrest.TypeSafeMatcher
fun withDrawable(
@DrawableRes id: Int,
@ColorRes tint: Int? = null,
tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN
) = object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("ImageView with drawable same as drawable with id $id")
tint?.let { description.appendText(", tint color id: $tint, mode: $tintMode") }
}
override fun matchesSafely(view: View): Boolean {
val context = view.context
val tintColor = tint?.toColor(context)
val expectedBitmap = context.getDrawable(id)?.tinted(tintColor, tintMode)?.toBitmap()
return view is ImageView && view.drawable.toBitmap().sameAs(expectedBitmap)
}
}
private fun Int.toColor(context: Context) = ContextCompat.getColor(context, this)
private fun Drawable.tinted(@ColorInt tintColor: Int? = null, tintMode: PorterDuff.Mode = PorterDuff.Mode.SRC_IN) =
apply {
setTintList(tintColor?.toColorStateList())
setTintMode(tintMode)
}
private fun Int.toColorStateList() = ColorStateList.valueOf(this)
@@ -0,0 +1,74 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.hasChildCount
import androidx.test.espresso.matcher.ViewMatchers.withId
import org.hamcrest.CoreMatchers
fun Int.isDisplayed(displayed: Boolean) {
val displayMatcher = if (displayed) ViewMatchers.isDisplayed() else CoreMatchers.not(ViewMatchers.isDisplayed())
onView(withId(this))
.check(matches(displayMatcher))
}
fun Int.isEnabled(enabled: Boolean) {
val enableMatcher = if (enabled) ViewMatchers.isEnabled() else CoreMatchers.not(ViewMatchers.isEnabled())
onView(withId(this))
.check(matches(enableMatcher))
}
fun Int.isFocusable(focusable: Boolean) {
val focusableMatcher = if (focusable) ViewMatchers.isFocusable() else CoreMatchers.not(ViewMatchers.isFocusable())
onView(withId(this))
.check(matches(focusableMatcher))
}
fun Int.withText(text: String) {
onView(withId(this))
.check(matches(ViewMatchers.withText(text)))
}
fun Int.withText(resourceId: Int) {
onView(withId(this))
.check(matches(ViewMatchers.withText(resourceId)))
}
fun Int.withChildCountAndId(count: Int, resourceId: Int) {
onView(withId(this))
.check(matches(withChildViewCount(count, withId(resourceId))))
}
fun Int.assertVisibility(visibility: ViewMatchers.Visibility) {
onView(withId(this))
.check(matches(ViewMatchers.withEffectiveVisibility(visibility)))
}
fun Int.assertChildCount(childs: Int) {
onView(withId(this))
.check(matches(hasChildCount(childs)))
}
@@ -0,0 +1,48 @@
/**
* qsfera Android client application
*
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2021 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import androidx.preference.Preference
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.junit.Assert.assertEquals
fun Preference.verifyPreference(
keyPref: String,
titlePref: String,
summaryPref: String? = null,
visible: Boolean,
enabled: Boolean? = null
) {
if (visible) onView(withText(titlePref)).check(matches(isDisplayed()))
summaryPref?.let {
if (visible) onView(withText(it)).check(matches(isDisplayed()))
assertEquals(it, summary)
}
assertEquals(keyPref, key)
assertEquals(titlePref, title)
assertEquals(visible, isVisible)
enabled?.let {
assertEquals(enabled, isEnabled)
}
}
@@ -0,0 +1,43 @@
/**
* qsfera Android client application
*
* @author Abel García de Prada
*
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils.matchers
import android.view.View
import android.widget.TextView
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.test.espresso.matcher.BoundedMatcher
import org.hamcrest.Description
import org.hamcrest.Matcher
fun withTextColor(
@ColorRes textColor: Int
): Matcher<View> =
object : BoundedMatcher<View, TextView>(TextView::class.java) {
override fun describeTo(description: Description) {
description.appendText("TextView with text color: $textColor")
}
override fun matchesSafely(view: TextView): Boolean {
val expectedColor = ContextCompat.getColor(view.context, textColor)
val actualColor = view.currentTextColor
return actualColor == expectedColor
}
}
@@ -0,0 +1,24 @@
<!--
~ Copyright (C) 2017 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity android:name="eu.qsfera.android.sharing.shares.ui.TestShareFileActivity" />
</application>
</manifest>
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.qsfera.android.testing
import android.os.Bundle
import android.view.ViewGroup
import android.widget.FrameLayout
import eu.qsfera.android.R
import eu.qsfera.android.ui.activity.BaseActivity
/**
* Used for testing fragments inside a fake activity.
*/
open class SingleFragmentActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val content = FrameLayout(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
id = R.id.container
}
setContentView(content)
}
}
@@ -0,0 +1,41 @@
/**
* qsfera Android client application
*
* @author Christian Schabesberger
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.utils
import android.content.Context
import android.os.Build
import android.os.StrictMode
import com.facebook.stetho.Stetho
object DebugInjector {
open fun injectDebugTools(context: Context) {
Stetho.initializeWithDefaults(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.penaltyLog()
.detectNonSdkApiUsage()
.detectUnsafeIntentLaunch()
.build()
)
}
}
}
@@ -0,0 +1,98 @@
package eu.qsfera.android
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.rule.GrantPermissionRule
import com.kaspersky.kaspresso.kaspresso.Kaspresso
import com.kaspersky.kaspresso.params.FlakySafetyParams
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import eu.qsfera.android.ui.activity.SplashActivity
import org.junit.Rule
import org.junit.Test
import screens.LoginScreen
import screens.MainScreen
import screens.ManageAccountsDialog
import screens.StartScreen
import screens.TrustCertificate
class LoginScreenTest : TestCase(
kaspressoBuilder = Kaspresso.Builder.advanced {
flakySafetyParams = FlakySafetyParams.custom(
timeoutMs = 20_000L,
intervalMs = 100L
)
}
) {
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.POST_NOTIFICATIONS
)
@get:Rule
val activityRule = ActivityScenarioRule(SplashActivity::class.java)
@Test
fun loginApp() {
before {
adbServer.performCmd("adb", listOf("reverse", "tcp:9200", "tcp:9200"))
}.after {
adbServer.performCmd("adb", listOf("shell", "am", "force-stop", "com.android.chrome"))
adbServer.performCmd("adb", listOf("reverse", "--remove", "tcp:9200"))
}.run {
step("set qsfera url") {
StartScreen {
hostUrlInput {
isVisible()
typeText("https://localhost:9200")
}
checkServerButton {
isVisible()
isClickable()
click()
}
}
}
step("trust certificate") {
TrustCertificate {
yesBtn {
isVisible()
isClickable()
click()
}
}
}
step("login") {
LoginScreen {
username.isDisplayed()
password.isDisplayed()
loginButton.isDisplayed()
username.typeText("alan")
password.typeText("demo")
loginButton.click()
keepAccessForeverBtn {
isDisplayed()
isClickable()
click()
}
}
}
step("check personal space") {
MainScreen {
avatarButton.isVisible()
avatarButton.isClickable()
avatarButton.click()
}
}
step("remove account") {
ManageAccountsDialog {
removeBtn {
isVisible()
click()
}
message.isVisible()
confirmBtn.click()
}
}
}
}
}
@@ -0,0 +1,16 @@
package screens
import com.kaspersky.components.kautomator.component.edit.UiEditText
import com.kaspersky.components.kautomator.component.text.UiButton
import com.kaspersky.components.kautomator.screen.UiScreen
object LoginScreen : UiScreen<LoginScreen>() {
override val packageName: String = "com.android.chrome"
// can't find it using withId("com.android.chrome", "username") so using withResourceName()
val username = UiEditText { withResourceName("oc-login-username") }
val password = UiEditText { withResourceName("oc-login-password") }
val loginButton = UiButton { withText("Log in") }
val keepAccessForeverBtn = UiButton { withText("Allow") }
}
@@ -0,0 +1,12 @@
package screens
import com.kaspersky.kaspresso.screens.KScreen
import eu.qsfera.android.R
import io.github.kakaocup.kakao.text.KButton
object MainScreen : KScreen<MainScreen>() {
override val layoutId: Int? = R.layout.activity_main
override val viewClass: Class<*>? = null
val avatarButton = KButton { withId(R.id.root_toolbar_avatar) }
}
@@ -0,0 +1,17 @@
package screens
import com.kaspersky.kaspresso.screens.KScreen
import eu.qsfera.android.R
import io.github.kakaocup.kakao.text.KButton
import io.github.kakaocup.kakao.text.KTextView
object ManageAccountsDialog : KScreen<ManageAccountsDialog>() {
override val layoutId: Int = R.layout.manage_accounts_dialog
override val viewClass: Class<*>? = null
val removeBtn = KButton { withId(R.id.removeButton) }
val message = KTextView {
containsText("Do you really want to remove the account")
}
val confirmBtn = KButton { withText(R.string.common_yes) }
}
@@ -0,0 +1,14 @@
package screens
import com.kaspersky.kaspresso.screens.KScreen
import eu.qsfera.android.R
import io.github.kakaocup.kakao.edit.KEditText
import io.github.kakaocup.kakao.text.KButton
object StartScreen : KScreen<StartScreen>() {
override val layoutId: Int? = R.layout.account_setup
override val viewClass: Class<*>? = null
val hostUrlInput = KEditText { withId(R.id.hostUrlInput) }
val checkServerButton = KButton { withId(R.id.embeddedCheckServerButton) }
}
@@ -0,0 +1,12 @@
package screens
import com.kaspersky.kaspresso.screens.KScreen
import eu.qsfera.android.R
import io.github.kakaocup.kakao.text.KButton
object TrustCertificate: KScreen<TrustCertificate>() {
override val layoutId: Int? = R.layout.ssl_untrusted_cert_layout
override val viewClass: Class<*>? = null
val yesBtn = KButton { withId(R.id.ok) }
}
@@ -0,0 +1,274 @@
<?xml version="1.0" encoding="utf-8"?><!--
qsfera Android client application
Copyright (C) 2012 Bartek Przybylski
Copyright (C) 2023 ownCloud GmbH.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2,
as published by the Free Software Foundation.
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
WRITE_EXTERNAL_STORAGE may be enabled or disabled by the user after installation in
API >= 23; the app needs to handle this
-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!--
Notifications are off by default since API 33;
See note in https://developer.android.com/develop/ui/views/notifications/notification-permission
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
Next permissions are always approved in installation time,
the apps needs to do nothing special in runtime
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.BROADCAST_STICKY" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REORDER_TASKS" />
<application
android:name=".MainApp"
android:allowBackup="false"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:preserveLegacyExternalStorage="true"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:supportsPictureInPicture="false"
android:taskAffinity=""
android:theme="@style/Theme.qsfera.Toolbar">
<activity
android:name=".ui.preview.PreviewVideoActivity"
android:label="@string/video_preview_label"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:theme="@style/Theme.qsfera.Video"
android:exported="false" />
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/managed_configurations" />
<activity
android:name=".presentation.releasenotes.ReleaseNotesActivity"
android:label="@string/release_notes_label"
android:exported="false" />
<activity
android:name=".presentation.settings.privacypolicy.PrivacyPolicyActivity"
android:label="@string/actionbar_privacy_policy" />
<activity android:name=".presentation.settings.SettingsActivity" />
<activity android:name=".presentation.migration.StorageMigrationActivity"
android:theme="@style/Theme.qsfera.Toolbar.Fill" />
<activity
android:name=".ui.activity.SplashActivity"
android:exported="true"
android:theme="@style/Theme.qsfera.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.activity.FileDisplayActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.qsfera.Toolbar.Drawer"
android:windowSoftInputMode="adjustPan" />
<activity
android:name=".ui.activity.ReceiveExternalFilesActivity"
android:configChanges="orientation|screenSize"
android:label="@string/receive_external_files_label"
android:excludeFromRecents="true"
android:exported="true"
android:taskAffinity="">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
android:name=".ui.preview.PreviewImageActivity"
android:label="@string/image_preview_label"
android:theme="@style/Theme.qsfera.Overlay" />
<service
android:name=".presentation.authentication.AccountAuthenticatorService"
android:exported="true">
<intent-filter android:priority="100">
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator" />
</service>
<service
android:name=".syncadapter.FileSyncService"
android:exported="true">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter_files" />
</service>
<provider
android:name=".providers.FileContentProvider"
android:authorities="@string/authority"
android:enabled="true"
android:exported="false"
android:label="@string/sync_string_files"
android:syncable="true" />
<provider
android:name=".presentation.sharing.sharees.UsersAndGroupsSearchProvider"
android:authorities="@string/search_suggest_authority"
android:enabled="true"
android:exported="false"
android:label="@string/search_users_and_groups_hint" />
<provider
android:name=".presentation.documentsprovider.DocumentsStorageProvider"
android:authorities="@string/document_provider_authority"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<!-- new provider used to generate URIs without file:// scheme (forbidden from Android 7) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/file_provider_authority"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/exposed_filepaths"
tools:replace="android:resource" />
</provider>
<service android:name=".services.OperationsService" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false"
tools:replace="android:foregroundServiceType" />
<service
android:name=".media.MediaService"
android:foregroundServiceType="mediaPlayback"
android:exported="false">
</service>
<activity
android:name=".presentation.security.passcode.PassCodeActivity"
android:label="@string/passcode_label"
android:screenOrientation="portrait" />
<activity
android:name=".presentation.conflicts.ConflictsResolveActivity" />
<activity
android:name=".presentation.logging.LogsListActivity"
android:label="@string/prefs_log_open_logs_list_view"
android:configChanges="orientation|screenSize" />
<activity android:name=".ui.errorhandling.ErrorShowActivity" />
<activity
android:name=".ui.activity.UploadListActivity"
android:label="@string/bottom_nav_uploads"
android:theme="@style/Theme.qsfera.Toolbar.Drawer" />
<activity
android:name=".ui.activity.WhatsNewActivity"
android:label="@string/whats_new_label"
android:configChanges="keyboardHidden|orientation|screenSize" />
<activity
android:name=".ui.activity.CopyToClipboardActivity"
android:icon="@drawable/copy_link"
android:label="@string/copy_link" />
<activity android:name=".ui.activity.FolderPickerActivity"
android:label="@string/folder_picker_label"/>
<activity
android:name=".presentation.sharing.ShareActivity"
android:exported="false"
android:label="@string/share_dialog_title"
android:launchMode="singleTop"
android:theme="@style/Theme.qsfera.Toolbar"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/users_and_groups_searchable" />
</activity>
<activity
android:name=".presentation.security.pattern.PatternActivity"
android:screenOrientation="portrait"
android:label="@string/pattern_label" />
<activity android:name=".presentation.security.biometric.BiometricActivity" />
<!-- Own taskAffinity + singleTask so LoginActivity always runs in its own task.
During re-auth, startActivityForResult overrides singleTask, so the first
instance still lands in the main task — but the OAuth redirect instance gets
its own task and finishes the orphaned one via a static reference.
autoRemoveFromRecents cleans the login task from recents once it finishes. -->
<activity
android:name=".presentation.authentication.LoginActivity"
android:autoRemoveFromRecents="true"
android:exported="true"
android:label="@string/login_label"
android:launchMode="singleTask"
android:taskAffinity="eu.qsfera.android.login"
android:theme="@style/Theme.qsfera.Toolbar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="@string/oauth2_redirect_uri_host"
android:scheme="@string/oauth2_redirect_uri_scheme" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/deep_link_uri_schemes" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,97 @@
/**
* qsfera Android client application
* <p>
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import eu.qsfera.android.ui.dialog.RateMeDialog;
import timber.log.Timber;
public class AppRater {
private static final String DIALOG_RATE_ME_TAG = "DIALOG_RATE_ME";
private final static int DAYS_UNTIL_PROMPT = 2;
private final static int LAUNCHES_UNTIL_PROMPT = 2;
private final static int DAYS_UNTIL_NEUTRAL_CLICK = 1;
public static final String APP_RATER_PREF_TITLE = "app_rater";
public static final String APP_RATER_PREF_DONT_SHOW = "don't_show_again";
private static final String APP_RATER_PREF_LAUNCH_COUNT = "launch_count";
private static final String APP_RATER_PREF_DATE_FIRST_LAUNCH = "date_first_launch";
public static final String APP_RATER_PREF_DATE_NEUTRAL = "date_neutral";
public static void appLaunched(Context mContext, String packageName) {
SharedPreferences prefs = mContext.getSharedPreferences(APP_RATER_PREF_TITLE, 0);
if (prefs.getBoolean(APP_RATER_PREF_DONT_SHOW, false)) {
Timber.d("Do not show the rate dialog again as the user decided");
return;
}
SharedPreferences.Editor editor = prefs.edit();
/// Increment launch counter
long launchCount = prefs.getLong(APP_RATER_PREF_LAUNCH_COUNT, 0) + 1;
Timber.d("The app has been launched " + launchCount + " times");
editor.putLong(APP_RATER_PREF_LAUNCH_COUNT, launchCount);
/// Get date of first launch
long dateFirstLaunch = prefs.getLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, 0);
if (dateFirstLaunch == 0) {
dateFirstLaunch = System.currentTimeMillis();
Timber.d("The app has been launched in " + dateFirstLaunch + " for the first time");
editor.putLong(APP_RATER_PREF_DATE_FIRST_LAUNCH, dateFirstLaunch);
}
/// Get date of neutral click
long dateNeutralClick = prefs.getLong(APP_RATER_PREF_DATE_NEUTRAL, 0);
/// Wait at least n days before opening
if (launchCount >= LAUNCHES_UNTIL_PROMPT) {
Timber.d("The number of launches already exceed " + LAUNCHES_UNTIL_PROMPT +
", the default number of launches, so let's check some dates");
Timber.d("Current moment is %s", System.currentTimeMillis());
Timber.d("The date of the first launch + days until prompt is " + dateFirstLaunch +
daysToMilliseconds(DAYS_UNTIL_PROMPT));
Timber.d("The date of the neutral click + days until neutral click is " + dateNeutralClick +
daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK));
if (System.currentTimeMillis() >= Math.max(dateFirstLaunch
+ daysToMilliseconds(DAYS_UNTIL_PROMPT), dateNeutralClick
+ daysToMilliseconds(DAYS_UNTIL_NEUTRAL_CLICK))) {
Timber.d("The current moment is later than any of the days set, so let's show the rate dialog");
showRateDialog(mContext, packageName);
}
}
editor.apply();
}
private static int daysToMilliseconds(int days) {
return days * 24 * 60 * 60 * 1000;
}
private static void showRateDialog(Context mContext, String packageName) {
RateMeDialog rateMeDialog = RateMeDialog.newInstance(packageName, false);
FragmentManager fm = ((FragmentActivity) mContext).getSupportFragmentManager();
FragmentTransaction ft = fm.beginTransaction();
rateMeDialog.show(ft, DIALOG_RATE_ME_TAG);
}
}
@@ -0,0 +1,437 @@
/**
* qsfera Android client application
*
* @author masensio
* @author David A. Velasco
* @author David González Verdugo
* @author Christian Schabesberger
* @author David Crespo Ríos
* @author Juan Carlos Garrote Gascón
* @author Aitor Ballesteros Pavón
*
* Copyright (C) 2024 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android
import android.app.Activity
import android.app.Application
import android.app.NotificationManager.IMPORTANCE_LOW
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.widget.CheckBox
import androidx.appcompat.app.AlertDialog
import androidx.core.content.pm.PackageInfoCompat
import eu.qsfera.android.data.providers.implementation.OCSharedPreferencesProvider
import eu.qsfera.android.db.PreferenceManager
import eu.qsfera.android.dependecyinjection.commonModule
import eu.qsfera.android.dependecyinjection.localDataSourceModule
import eu.qsfera.android.dependecyinjection.remoteDataSourceModule
import eu.qsfera.android.dependecyinjection.repositoryModule
import eu.qsfera.android.dependecyinjection.useCaseModule
import eu.qsfera.android.dependecyinjection.viewModelModule
import eu.qsfera.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase
import eu.qsfera.android.domain.spaces.model.OCSpace
import eu.qsfera.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase
import eu.qsfera.android.domain.user.usecases.GetStoredQuotaUseCase
import eu.qsfera.android.extensions.createNotificationChannel
import eu.qsfera.android.lib.common.SingleSessionManager
import eu.qsfera.android.presentation.authentication.AccountUtils
import eu.qsfera.android.presentation.migration.StorageMigrationActivity
import eu.qsfera.android.presentation.releasenotes.ReleaseNotesActivity
import eu.qsfera.android.presentation.security.biometric.BiometricActivity
import eu.qsfera.android.presentation.security.biometric.BiometricManager
import eu.qsfera.android.presentation.security.passcode.PassCodeActivity
import eu.qsfera.android.presentation.security.passcode.PassCodeManager
import eu.qsfera.android.presentation.security.pattern.PatternActivity
import eu.qsfera.android.presentation.security.pattern.PatternManager
import eu.qsfera.android.presentation.settings.logging.SettingsLogsFragment.Companion.PREFERENCE_ENABLE_LOGGING
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.LogsProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.ui.activity.FileDisplayActivity
import eu.qsfera.android.ui.activity.FileDisplayActivity.Companion.PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED
import eu.qsfera.android.ui.activity.WhatsNewActivity
import eu.qsfera.android.utils.CONFIGURATION_ALLOW_SCREENSHOTS
import eu.qsfera.android.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.DebugInjector
import eu.qsfera.android.utils.FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.FILE_SYNC_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID
import eu.qsfera.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import timber.log.Timber
/**
* Main Application of the project
*
*
* Contains methods to build the "static" strings. These strings were before constants in different
* classes
*/
class MainApp : Application() {
override fun onCreate() {
super.onCreate()
appContext = applicationContext
// Ensure Logcat shows Timber logs in debug builds
if (BuildConfig.DEBUG) {
try {
Timber.plant(Timber.DebugTree())
} catch (_: Throwable) {
// ignore if already planted
}
}
startLogsIfEnabled()
DebugInjector.injectDebugTools(appContext)
createNotificationChannels()
SingleSessionManager.setUserAgent(userAgent)
initDependencyInjection()
val workManagerProvider: WorkManagerProvider by inject()
var startedActivities = 0
// register global protection with pass code, pattern lock and biometric lock
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
Timber.d("${activity.javaClass.simpleName} onCreate(Bundle) starting")
// To prevent taking screenshots in MDM
if (!areScreenshotsAllowed()) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
// If there's any lock protection, don't show wizard at this point, show it when lock activities
// have finished
if (activity !is PassCodeActivity &&
activity !is PatternActivity &&
activity !is BiometricActivity
) {
StorageMigrationActivity.runIfNeeded(activity)
if (isFirstRun()) {
WhatsNewActivity.runIfNeeded(activity)
} else {
ReleaseNotesActivity.runIfNeeded(activity)
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
val clearDataAlreadyTriggered = pref.contains(PREFERENCE_CLEAR_DATA_ALREADY_TRIGGERED)
if (clearDataAlreadyTriggered || isNewVersionCode()) {
val dontShowAgainDialogPref = pref.getBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, false)
if (!dontShowAgainDialogPref && shouldShowDialog(activity)) {
val checkboxDialog = activity.layoutInflater.inflate(R.layout.checkbox_dialog, null)
val checkbox = checkboxDialog.findViewById<CheckBox>(R.id.checkbox_dialog)
checkbox.setText(R.string.server_accounts_warning_checkbox_message)
val builder = AlertDialog.Builder(activity).apply {
setView(checkboxDialog)
setTitle(R.string.server_accounts_warning_title)
setMessage(R.string.server_accounts_warning_message)
setCancelable(false)
setPositiveButton(R.string.server_accounts_warning_button) { _, _ ->
if (checkbox.isChecked) {
pref.edit().putBoolean(PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG, true).apply()
}
}
}
val alertDialog = builder.create()
alertDialog.show()
}
} else { // "Clear data" button is pressed from the app settings in the device settings.
AccountUtils.deleteAccounts(appContext)
WhatsNewActivity.runIfNeeded(activity)
}
}
}
PreferenceManager.migrateFingerprintToBiometricKey(applicationContext)
PreferenceManager.deleteOldSettingsPreferences(applicationContext)
}
private fun shouldShowDialog(activity: Activity) =
runBlocking(CoroutinesDispatcherProvider().io) {
if (activity !is FileDisplayActivity) return@runBlocking false
val account = AccountUtils.getCurrentQSferaAccount(appContext) ?: return@runBlocking false
val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
val capabilities = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getStoredCapabilitiesUseCase(
GetStoredCapabilitiesUseCase.Params(
accountName = account.name
)
)
}
val spacesAllowed = capabilities != null && capabilities.isSpacesAllowed()
var personalSpace: OCSpace? = null
if (spacesAllowed) {
val getPersonalSpaceForAccountUseCase: GetPersonalSpaceForAccountUseCase by inject()
personalSpace = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getPersonalSpaceForAccountUseCase(
GetPersonalSpaceForAccountUseCase.Params(
accountName = account.name
)
)
}
}
val getStoredQuotaUseCase: GetStoredQuotaUseCase by inject()
val quota = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getStoredQuotaUseCase(
GetStoredQuotaUseCase.Params(
accountName = account.name
)
)
}
val isLightUser = quota.getDataOrNull()?.available == -4L
spacesAllowed && personalSpace == null && !isLightUser
}
override fun onActivityStarted(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onStart() starting")
if (startedActivities == 0) {
// App entered foreground — ensure the periodic worker is registered
// (recovers if the chain was dropped) and trigger an immediate scan
// so the user doesn't have to wait up to 15 min.
CoroutineScope(Dispatchers.IO).launch {
workManagerProvider.enqueueAutomaticUploadsWorker()
workManagerProvider.enqueueMediaStoreAutomaticUploadsWorker()
workManagerProvider.enqueueImmediateAutomaticUploadsWorker()
}
}
startedActivities++
PassCodeManager.onActivityStarted(activity)
PatternManager.onActivityStarted(activity)
BiometricManager.onActivityStarted(activity)
}
override fun onActivityResumed(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onResume() starting")
}
override fun onActivityPaused(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onPause() ending")
}
override fun onActivityStopped(activity: Activity) {
startedActivities--
Timber.v("${activity.javaClass.simpleName} onStop() ending")
PassCodeManager.onActivityStopped(activity)
PatternManager.onActivityStopped(activity)
BiometricManager.onActivityStopped(activity)
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
Timber.v("${activity.javaClass.simpleName} onSaveInstanceState(Bundle) starting")
}
override fun onActivityDestroyed(activity: Activity) {
Timber.v("${activity.javaClass.simpleName} onDestroy() ending")
}
})
}
private fun startLogsIfEnabled() {
val preferenceProvider = OCSharedPreferencesProvider(applicationContext)
if (BuildConfig.DEBUG) {
val alreadySet = preferenceProvider.containsPreference(PREFERENCE_ENABLE_LOGGING)
if (!alreadySet) {
preferenceProvider.putBoolean(PREFERENCE_ENABLE_LOGGING, true)
}
}
enabledLogging = preferenceProvider.getBoolean(PREFERENCE_ENABLE_LOGGING, false)
if (enabledLogging) {
val mdmProvider = MdmProvider(applicationContext)
LogsProvider(applicationContext, mdmProvider).startLogging()
}
}
/**
* Screenshots allowed in debug mode. Devs and tests <3
* Otherwise, depends on branding.
*/
private fun areScreenshotsAllowed(): Boolean {
if (BuildConfig.DEBUG) return true
val mdmProvider = MdmProvider(applicationContext)
return mdmProvider.getBrandingBoolean(CONFIGURATION_ALLOW_SCREENSHOTS, R.bool.allow_screenshots)
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
createNotificationChannel(
id = DOWNLOAD_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.download_notification_channel_name),
description = getString(R.string.download_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = UPLOAD_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.upload_notification_channel_name),
description = getString(R.string.upload_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = MEDIA_SERVICE_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.media_service_notification_channel_name),
description = getString(R.string.media_service_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = FILE_SYNC_CONFLICT_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_conflict_notification_channel_name),
description = getString(R.string.file_sync_conflict_notification_channel_description),
importance = IMPORTANCE_LOW
)
createNotificationChannel(
id = FILE_SYNC_NOTIFICATION_CHANNEL_ID,
name = getString(R.string.file_sync_notification_channel_name),
description = getString(R.string.file_sync_notification_channel_description),
importance = IMPORTANCE_LOW
)
}
private fun isFirstRun(): Boolean {
if (getLastSeenVersionCode() != 0) {
return false
}
return AccountUtils.getCurrentQSferaAccount(appContext) == null
}
companion object {
const val MDM_FLAVOR = "mdm"
lateinit var appContext: Context
private set
var enabledLogging: Boolean = false
private set
const val PREFERENCE_KEY_LAST_SEEN_VERSION_CODE = "lastSeenVersionCode"
const val PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG = "PREFERENCE_KEY_DONT_SHOW_SERVER_ACCOUNT_WARNING_DIALOG"
/**
* Next methods give access in code to some constants that need to be defined in string resources to be referred
* in AndroidManifest.xml file or other xml resource files; or that need to be easy to modify in build time.
*/
val accountType: String
get() = appContext.resources.getString(R.string.account_type)
val versionCode: Int
get() =
try {
val pInfo: PackageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
val longVersionCode: Long = PackageInfoCompat.getLongVersionCode(pInfo)
longVersionCode.toInt()
} catch (e: PackageManager.NameNotFoundException) {
Timber.w(e, "Version code not found, using 0 as fallback")
0
}
val authority: String
get() = appContext.resources.getString(R.string.authority)
val authTokenType: String
get() = appContext.resources.getString(R.string.authority)
val dataFolder: String
get() = appContext.resources.getString(R.string.data_folder)
// user agent
// Mozilla/5.0 (Android) qsfera-android/1.7.0
val userAgent: String
get() {
val appString = appContext.resources.getString(R.string.user_agent)
val packageName = appContext.packageName
var version: String? = ""
val pInfo: PackageInfo?
try {
pInfo = appContext.packageManager.getPackageInfo(packageName, 0)
if (pInfo != null) {
version = pInfo.versionName
}
} catch (e: PackageManager.NameNotFoundException) {
Timber.e(e, "Trying to get packageName")
}
return String.format(appString, version)
}
fun initDependencyInjection() {
stopKoin()
startKoin {
androidContext(appContext)
modules(
listOf(
commonModule,
viewModelModule,
useCaseModule,
repositoryModule,
localDataSourceModule,
remoteDataSourceModule
)
)
}
}
fun getLastSeenVersionCode(): Int {
val pref = PreferenceManager.getDefaultSharedPreferences(appContext)
return pref.getInt(PREFERENCE_KEY_LAST_SEEN_VERSION_CODE, 0)
}
private fun isNewVersionCode(): Boolean {
val lastSeenVersionCode = getLastSeenVersionCode()
if (lastSeenVersionCode == 0) { // The preferences have been deleted, so we can delete the accounts and navigate to login
return false
}
return lastSeenVersionCode != versionCode // The version has changed and the accounts must not be deleted
}
}
}
@@ -0,0 +1,123 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author Christian Schabesberger
* @author David González Verdugo
* @author Abel García de Prada
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2012 Bartek Przybylski
* Copyright (C) 2023 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package eu.qsfera.android.datamodel
import android.accounts.Account
import eu.qsfera.android.domain.files.model.OCFile
import eu.qsfera.android.domain.files.model.OCFile.Companion.ROOT_PATH
import eu.qsfera.android.domain.files.usecases.GetFileByIdUseCase
import eu.qsfera.android.domain.files.usecases.GetFileByRemotePathUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderContentUseCase
import eu.qsfera.android.domain.files.usecases.GetFolderImagesUseCase
import eu.qsfera.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase
import eu.qsfera.android.domain.files.usecases.GetSharesRootFolderForAccount
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class FileDataStorageManager(
val account: Account,
) : KoinComponent {
fun getFileByPath(remotePath: String, spaceId: String? = null): OCFile? =
if (remotePath == ROOT_PATH && spaceId == null) {
getRootPersonalFolder()
} else {
getFileByPathAndAccount(remotePath, account.name, spaceId)
}
private fun getFileByPathAndAccount(remotePath: String, accountName: String, spaceId: String? = null): OCFile? =
runBlocking(CoroutinesDispatcherProvider().io) {
val getFileByRemotePathUseCase: GetFileByRemotePathUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(accountName, remotePath, spaceId))
}.getDataOrNull()
result
}
fun getRootPersonalFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
val getPersonalRootFolderForAccountUseCase: GetPersonalRootFolderForAccountUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getPersonalRootFolderForAccountUseCase(GetPersonalRootFolderForAccountUseCase.Params(account.name))
}
result
}
fun getRootSharesFolder() = runBlocking(CoroutinesDispatcherProvider().io) {
val getSharesRootFolderForAccount: GetSharesRootFolderForAccount by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getSharesRootFolderForAccount(GetSharesRootFolderForAccount.Params(account.name))
}
result
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
fun getFileById(id: Long): OCFile? = runBlocking(CoroutinesDispatcherProvider().io) {
val getFileByIdUseCase: GetFileByIdUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFileByIdUseCase(GetFileByIdUseCase.Params(id))
}.getDataOrNull()
result
}
fun fileExists(path: String): Boolean = getFileByPath(path) != null
fun getFolderContent(f: OCFile?): List<OCFile> =
if (f != null && f.isFolder && f.id != -1L) {
// To do: Remove !!
getFolderContent(f.id!!)
} else {
listOf()
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
fun getFolderImages(folder: OCFile?): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
val getFolderImagesUseCase: GetFolderImagesUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
// To do: Remove !!
getFolderImagesUseCase(GetFolderImagesUseCase.Params(folderId = folder!!.id!!))
}.getDataOrNull()
result ?: listOf()
}
// To do: New_arch. Remove this and call usecase inside FilesViewModel
private fun getFolderContent(parentId: Long): List<OCFile> = runBlocking(CoroutinesDispatcherProvider().io) {
val getFolderContentUseCase: GetFolderContentUseCase by inject()
val result = withContext(CoroutineScope(CoroutinesDispatcherProvider().io).coroutineContext) {
getFolderContentUseCase(GetFolderContentUseCase.Params(parentId))
}.getDataOrNull()
result ?: listOf()
}
}
@@ -0,0 +1,226 @@
/*
* qsfera Android client application
*
* @author David A. Velasco
* @author David González Verdugo
* @author Shashvat Kedia
* @author Juan Carlos Garrote Gascón
*
* Copyright (C) 2020 ownCloud GmbH.
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
* <p>
* This program 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.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.db;
import android.content.Context;
import android.content.SharedPreferences;
import eu.qsfera.android.presentation.security.biometric.BiometricActivity;
import eu.qsfera.android.utils.FileStorageUtils;
/**
* Helper to simplify reading of Preferences all around the app
*/
public abstract class PreferenceManager {
// Legacy preferences - done in version 2.18
public static final String PREF__LEGACY_CLICK_DEV_MENU = "clickDeveloperMenu";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED = "camera_picture_uploads";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED = "camera_video_uploads";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "camera_picture_uploads_on_wifi";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "camera_video_uploads_on_wifi";
public static final String PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH = "camera_picture_uploads_path";
public static final String PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH = "camera_video_uploads_path";
public static final String PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR = "camera_uploads_behaviour";
public static final String PREF__LEGACY_CAMERA_UPLOADS_SOURCE = "camera_uploads_source_path";
public static final String PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME = "camera_uploads_account_name";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ENABLED = "enable_picture_uploads";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ENABLED = "enable_video_uploads";
public static final String PREF__CAMERA_PICTURE_UPLOADS_WIFI_ONLY = "picture_uploads_on_wifi";
public static final String PREF__CAMERA_PICTURE_UPLOADS_CHARGING_ONLY = "picture_uploads_on_charging";
public static final String PREF__CAMERA_VIDEO_UPLOADS_WIFI_ONLY = "video_uploads_on_wifi";
public static final String PREF__CAMERA_VIDEO_UPLOADS_CHARGING_ONLY = "video_uploads_on_charging";
public static final String PREF__CAMERA_PICTURE_UPLOADS_PATH = "picture_uploads_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_PATH = "video_uploads_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_BEHAVIOUR = "picture_uploads_behaviour";
public static final String PREF__CAMERA_PICTURE_UPLOADS_SOURCE = "picture_uploads_source_path";
public static final String PREF__CAMERA_VIDEO_UPLOADS_BEHAVIOUR = "video_uploads_behaviour";
public static final String PREF__CAMERA_VIDEO_UPLOADS_SOURCE = "video_uploads_source_path";
public static final String PREF__CAMERA_PICTURE_UPLOADS_ACCOUNT_NAME = "picture_uploads_account_name";
public static final String PREF__CAMERA_VIDEO_UPLOADS_ACCOUNT_NAME = "video_uploads_account_name";
public static final String PREF__CAMERA_PICTURE_UPLOADS_LAST_SYNC = "picture_uploads_last_sync";
public static final String PREF__CAMERA_VIDEO_UPLOADS_LAST_SYNC = "video_uploads_last_sync";
public static final String PREF__CAMERA_UPLOADS_DEFAULT_PATH = "/CameraUpload";
public static final String PREF__LEGACY_FINGERPRINT = "set_fingerprint";
/**
* Constant to access value of last path selected by the user to upload a file shared from other app.
* Value handled by the app without direct access in the UI.
*/
private static final String AUTO_PREF__LAST_UPLOAD_PATH = "last_upload_path";
private static final String AUTO_PREF__SORT_ORDER_FILE_DISP = "sortOrderFileDisp";
private static final String AUTO_PREF__SORT_ASCENDING_FILE_DISP = "sortAscendingFileDisp";
private static final String AUTO_PREF__SORT_ORDER_UPLOAD = "sortOrderUpload";
private static final String AUTO_PREF__SORT_ASCENDING_UPLOAD = "sortAscendingUpload";
public static void migrateFingerprintToBiometricKey(Context context) {
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
// Check if legacy fingerprint key exists, delete it and migrate its value to the new key
if (sharedPref.contains(PREF__LEGACY_FINGERPRINT)) {
boolean currentFingerprintValue = sharedPref.getBoolean(PREF__LEGACY_FINGERPRINT, false);
SharedPreferences.Editor editor = sharedPref.edit();
editor.remove(PREF__LEGACY_FINGERPRINT);
editor.putBoolean(BiometricActivity.PREFERENCE_SET_BIOMETRIC, currentFingerprintValue);
editor.apply();
}
}
public static void deleteOldSettingsPreferences(Context context) {
SharedPreferences sharedPref = getDefaultSharedPreferences(context);
SharedPreferences.Editor editor = sharedPref.edit();
if (sharedPref.contains(PREF__LEGACY_CLICK_DEV_MENU)) {
editor.remove(PREF__LEGACY_CLICK_DEV_MENU);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_ENABLED);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_ENABLED);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_WIFI_ONLY);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_WIFI_ONLY);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH)) {
editor.remove(PREF__LEGACY_CAMERA_PICTURE_UPLOADS_PATH);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH)) {
editor.remove(PREF__LEGACY_CAMERA_VIDEO_UPLOADS_PATH);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_BEHAVIOUR);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_SOURCE)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_SOURCE);
}
if (sharedPref.contains(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME)) {
editor.remove(PREF__LEGACY_CAMERA_UPLOADS_ACCOUNT_NAME);
}
editor.apply();
}
/**
* Gets the path where the user selected to do the last upload of a file shared from other app.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return path Absolute path to a folder, as previously stored by {@link #setLastUploadPath(String, Context)},
* or empty String if never saved before.
*/
public static String getLastUploadPath(Context context) {
return getDefaultSharedPreferences(context).getString(AUTO_PREF__LAST_UPLOAD_PATH, "");
}
/**
* Saves the path where the user selected to do the last upload of a file shared from other app.
*
* @param path Absolute path to a folder.
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setLastUploadPath(String path, Context context) {
saveStringPreference(AUTO_PREF__LAST_UPLOAD_PATH, path, context);
}
/**
* Gets the sort order which the user has set last.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return sort order the sort order, default is {@link FileStorageUtils#SORT_NAME} (sort by name)
*/
public static int getSortOrder(Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
return getDefaultSharedPreferences(context)
.getInt(AUTO_PREF__SORT_ORDER_FILE_DISP, FileStorageUtils.SORT_NAME);
} else {
return getDefaultSharedPreferences(context)
.getInt(AUTO_PREF__SORT_ORDER_UPLOAD, FileStorageUtils.SORT_DATE);
}
}
/**
* Save the sort order which the user has set last.
*
* @param order the sort order
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setSortOrder(int order, Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
saveIntPreference(AUTO_PREF__SORT_ORDER_FILE_DISP, order, context);
} else {
saveIntPreference(AUTO_PREF__SORT_ORDER_UPLOAD, order, context);
}
}
/**
* Gets the ascending order flag which the user has set last.
*
* @param context Caller {@link Context}, used to access to shared preferences manager.
* @return ascending order the ascending order, default is true
*/
public static boolean getSortAscending(Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
return getDefaultSharedPreferences(context)
.getBoolean(AUTO_PREF__SORT_ASCENDING_FILE_DISP, true);
} else {
return getDefaultSharedPreferences(context)
.getBoolean(AUTO_PREF__SORT_ASCENDING_UPLOAD, true);
}
}
/**
* Saves the ascending order flag which the user has set last.
*
* @param ascending flag if sorting is ascending or descending
* @param context Caller {@link Context}, used to access to shared preferences manager.
*/
public static void setSortAscending(boolean ascending, Context context, int flag) {
if (flag == FileStorageUtils.FILE_DISPLAY_SORT) {
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_FILE_DISP, ascending, context);
} else {
saveBooleanPreference(AUTO_PREF__SORT_ASCENDING_UPLOAD, ascending, context);
}
}
private static void saveBooleanPreference(String key, boolean value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putBoolean(key, value);
appPreferences.apply();
}
private static void saveStringPreference(String key, String value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putString(key, value);
appPreferences.apply();
}
private static void saveIntPreference(String key, int value, Context context) {
SharedPreferences.Editor appPreferences = getDefaultSharedPreferences(context.getApplicationContext()).edit();
appPreferences.putInt(key, value);
appPreferences.apply();
}
public static SharedPreferences getDefaultSharedPreferences(Context context) {
return android.preference.PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
}
}
@@ -0,0 +1,198 @@
/**
* qsfera Android client application
*
* @author Bartek Przybylski
* @author David A. Velasco
* @author masensio
* @author David González Verdugo
* @author Abel García de Prada
* Copyright (C) 2011 Bartek Przybylski
* Copyright (C) 2020 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.db;
import android.net.Uri;
import android.provider.BaseColumns;
import eu.qsfera.android.MainApp;
/**
* Meta-Class that holds various static field information
* This is only used in FileContentProvider for legacy DB
*/
@Deprecated
public class ProviderMeta {
private ProviderMeta() {
}
static public class ProviderTableMeta implements BaseColumns {
public static final String FILE_TABLE_NAME = "filelist";
public static final String OCSHARES_TABLE_NAME = "ocshares";
public static final String CAPABILITIES_TABLE_NAME = "capabilities";
public static final String UPLOADS_TABLE_NAME = "list_of_uploads";
public static final String USER_AVATARS__TABLE_NAME = "user_avatars";
public static final String CAMERA_UPLOADS_SYNC_TABLE_NAME = "camera_uploads_sync";
public static final String USER_QUOTAS_TABLE_NAME = "user_quotas";
public static final Uri CONTENT_URI = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/");
public static final Uri CONTENT_URI_FILE = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/file");
public static final Uri CONTENT_URI_DIR = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/dir");
public static final Uri CONTENT_URI_SHARE = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/shares");
public static final Uri CONTENT_URI_CAPABILITIES = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/capabilities");
public static final Uri CONTENT_URI_UPLOADS = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/uploads");
public static final Uri CONTENT_URI_CAMERA_UPLOADS_SYNC = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/cameraUploadsSync");
public static final Uri CONTENT_URI_QUOTAS = Uri.parse("content://"
+ MainApp.Companion.getAuthority() + "/quotas");
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.qsfera.file";
public static final String CONTENT_TYPE_ITEM = "vnd.android.cursor.item/vnd.qsfera.file";
public static final String ID = "id";
// Columns of filelist table
public static final String FILE_PARENT = "parent";
public static final String FILE_NAME = "filename";
public static final String FILE_CREATION = "created";
public static final String FILE_MODIFIED = "modified";
public static final String FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA = "modified_at_last_sync_for_data";
public static final String FILE_CONTENT_LENGTH = "content_length";
public static final String FILE_CONTENT_TYPE = "content_type";
public static final String FILE_STORAGE_PATH = "media_path";
public static final String FILE_PATH = "path";
public static final String FILE_ACCOUNT_OWNER = "file_owner";
public static final String FILE_LAST_SYNC_DATE = "last_sync_date";// _for_properties, but let's keep it as it is
public static final String FILE_LAST_SYNC_DATE_FOR_DATA = "last_sync_date_for_data";
public static final String FILE_KEEP_IN_SYNC = "keep_in_sync";
public static final String FILE_ETAG = "etag";
public static final String FILE_TREE_ETAG = "tree_etag";
public static final String FILE_SHARED_VIA_LINK = "share_by_link";
public static final String FILE_SHARED_WITH_SHAREE = "shared_via_users";
public static final String FILE_PERMISSIONS = "permissions";
public static final String FILE_REMOTE_ID = "remote_id";
public static final String FILE_UPDATE_THUMBNAIL = "update_thumbnail";
public static final String FILE_IS_DOWNLOADING = "is_downloading";
public static final String FILE_ETAG_IN_CONFLICT = "etag_in_conflict";
public static final String FILE_PRIVATE_LINK = "private_link";
public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME
+ " collate nocase asc";
// @deprecated
public static final String FILE_PUBLIC_LINK = "public_link";
// Columns of ocshares table
public static final String OCSHARES_SHARE_TYPE = "share_type";
public static final String OCSHARES_SHARE_WITH = "share_with";
public static final String OCSHARES_PATH = "path";
public static final String OCSHARES_PERMISSIONS = "permissions";
public static final String OCSHARES_SHARED_DATE = "shared_date";
public static final String OCSHARES_EXPIRATION_DATE = "expiration_date";
public static final String OCSHARES_TOKEN = "token";
public static final String OCSHARES_SHARE_WITH_DISPLAY_NAME = "shared_with_display_name";
public static final String OCSHARES_SHARE_WITH_ADDITIONAL_INFO = "share_with_additional_info";
public static final String OCSHARES_IS_DIRECTORY = "is_directory";
public static final String OCSHARES_ID_REMOTE_SHARED = "id_remote_shared";
public static final String OCSHARES_ACCOUNT_OWNER = "owner_share";
public static final String OCSHARES_NAME = "name";
public static final String OCSHARES_URL = "url";
public static final String OCSHARES_DEFAULT_SORT_ORDER = OCSHARES_ID_REMOTE_SHARED
+ " collate nocase asc";
// Columns of capabilities table
public static final String CAPABILITIES_ACCOUNT_NAME = "account";
public static final String CAPABILITIES_VERSION_MAYOR = "version_mayor";
public static final String CAPABILITIES_VERSION_MINOR = "version_minor";
public static final String CAPABILITIES_VERSION_MICRO = "version_micro";
public static final String CAPABILITIES_VERSION_STRING = "version_string";
public static final String CAPABILITIES_VERSION_EDITION = "version_edition";
public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval";
public static final String CAPABILITIES_DAV_CHUNKING_VERSION = "dav_chunking_version";
public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_ENABLED = "sharing_public_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED = "sharing_public_password_enforced";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_ONLY =
"sharing_public_password_enforced_read_only";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_READ_WRITE =
"sharing_public_password_enforced_read_write";
public static final String CAPABILITIES_SHARING_PUBLIC_PASSWORD_ENFORCED_UPLOAD_ONLY =
"sharing_public_password_enforced_public_only";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENABLED =
"sharing_public_expire_date_enabled";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_DAYS =
"sharing_public_expire_date_days";
public static final String CAPABILITIES_SHARING_PUBLIC_EXPIRE_DATE_ENFORCED =
"sharing_public_expire_date_enforced";
public static final String CAPABILITIES_SHARING_PUBLIC_UPLOAD = "sharing_public_upload";
public static final String CAPABILITIES_SHARING_PUBLIC_MULTIPLE = "sharing_public_multiple";
public static final String CAPABILITIES_SHARING_PUBLIC_SUPPORTS_UPLOAD_ONLY = "supports_upload_only";
public static final String CAPABILITIES_SHARING_RESHARING = "sharing_resharing";
public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing";
public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming";
public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking";
public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete";
public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning";
public static final String CAPABILITIES_DEFAULT_SORT_ORDER = CAPABILITIES_ACCOUNT_NAME
+ " collate nocase asc";
//Columns of Uploads table
public static final String UPLOADS_LOCAL_PATH = "local_path";
public static final String UPLOADS_REMOTE_PATH = "remote_path";
public static final String UPLOADS_ACCOUNT_NAME = "account_name";
public static final String UPLOADS_FILE_SIZE = "file_size";
public static final String UPLOADS_STATUS = "status";
public static final String UPLOADS_LOCAL_BEHAVIOUR = "local_behaviour";
public static final String UPLOADS_UPLOAD_TIME = "upload_time";
public static final String UPLOADS_FORCE_OVERWRITE = "force_overwrite";
public static final String UPLOADS_IS_CREATE_REMOTE_FOLDER = "is_create_remote_folder";
public static final String UPLOADS_UPLOAD_END_TIMESTAMP = "upload_end_timestamp";
public static final String UPLOADS_LAST_RESULT = "last_result";
public static final String UPLOADS_CREATED_BY = "created_by";
public static final String UPLOADS_TRANSFER_ID = "transfer_id";
public static final String UPLOADS_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase desc";
// Columns of user_avatars table
public static final String USER_AVATARS__ACCOUNT_NAME = "account_name";
public static final String USER_AVATARS__CACHE_KEY = "cache_key";
public static final String USER_AVATARS__ETAG = "etag";
public static final String USER_AVATARS__MIME_TYPE = "mime_type";
// Columns of camera upload synchronization table
public static final String PICTURES_LAST_SYNC_TIMESTAMP = "pictures_last_sync_date";
public static final String VIDEOS_LAST_SYNC_TIMESTAMP = "videos_last_sync_date";
public static final String CAMERA_UPLOADS_SYNC_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase asc";
// Columns of user_quotas table
public static final String USER_QUOTAS__ACCOUNT_NAME = "account_name";
public static final String USER_QUOTAS__FREE = "free";
public static final String USER_QUOTAS__RELATIVE = "relative";
public static final String USER_QUOTAS__TOTAL = "total";
public static final String USER_QUOTAS__USED = "used";
public static final String USER_QUOTAS_DEFAULT_SORT_ORDER =
ProviderTableMeta._ID + " collate nocase asc";
}
}
@@ -0,0 +1,46 @@
/**
* qsfera Android client application
*
* @author David González Verdugo
* @author Abel García de Prada
* Copyright (C) 2021 ownCloud GmbH.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.qsfera.android.dependecyinjection
import androidx.work.WorkManager
import eu.qsfera.android.providers.AccountProvider
import eu.qsfera.android.providers.ContextProvider
import eu.qsfera.android.providers.CoroutinesDispatcherProvider
import eu.qsfera.android.providers.LogsProvider
import eu.qsfera.android.providers.MdmProvider
import eu.qsfera.android.providers.WorkManagerProvider
import eu.qsfera.android.providers.implementation.OCContextProvider
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val commonModule = module {
single { CoroutinesDispatcherProvider() }
factory<ContextProvider> { OCContextProvider(androidContext()) }
single { LogsProvider(get(), get()) }
single { MdmProvider(androidContext()) }
single { WorkManagerProvider(androidContext()) }
single { AccountProvider(androidContext()) }
single { WorkManager.getInstance(androidApplication()) }
}

Some files were not shown because too many files have changed in this diff Show More