diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..3682168027 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,906 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 160 +tab_width = 4 +ij_continuation_indent_size = 4 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = false +ij_visual_guides = none +ij_wrap_on_typing = false + +# Ktlint rule, for more information see https://pinterest.github.io/ktlint/faq/#why-is-editorconfig-property-disabled_rules-deprecated-and-how-do-i-resolve-this +ktlint_standard_wrapping = disabled +ktlint_standard_trailing-comma-on-call-site = disabled + + +[*.java] +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = true +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = true +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = off +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = false +ij_java_binary_operation_wrap = off +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 0 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = none +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 99 +ij_java_class_names_in_javadoc = 1 +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_while_brace_force = never +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_enum_constants_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = off +ij_java_field_annotation_wrap = split_into_lines +ij_java_finally_on_new_line = false +ij_java_for_brace_force = never +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = off +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_if_brace_force = never +ij_java_imports_layout = $android.**, $androidx.**, $com.**, $junit.**, $net.**, $org.**, $java.**, $javax.**, $*, |, android.**, |, androidx.**, |, com.**, |, junit.**, |, net.**, |, org.**, |, java.**, |, javax.**, |, *, | +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = false +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 2 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = true +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_at_first_column = true +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = off +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_new_line_after_lparen_in_record_header = false +ij_java_parameter_annotation_wrap = off +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_record_header = false +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = true +ij_java_space_before_catch_left_brace = true +ij_java_space_before_catch_parentheses = true +ij_java_space_before_class_left_brace = true +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_do_left_brace = true +ij_java_space_before_else_keyword = true +ij_java_space_before_else_left_brace = true +ij_java_space_before_finally_keyword = true +ij_java_space_before_finally_left_brace = true +ij_java_space_before_for_left_brace = true +ij_java_space_before_for_parentheses = true +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = true +ij_java_space_before_if_parentheses = true +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = true +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = true +ij_java_space_before_switch_parentheses = true +ij_java_space_before_synchronized_left_brace = true +ij_java_space_before_synchronized_parentheses = true +ij_java_space_before_try_left_brace = true +ij_java_space_before_try_parentheses = true +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = true +ij_java_space_before_while_left_brace = true +ij_java_space_before_while_parentheses = true +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = true +ij_java_spaces_around_assignment_operators = true +ij_java_spaces_around_bitwise_operators = true +ij_java_spaces_around_equality_operators = true +ij_java_spaces_around_lambda_arrow = true +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = true +ij_java_spaces_around_relational_operators = true +ij_java_spaces_around_shift_operators = true +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_subclass_name_suffix = Impl +ij_java_ternary_operation_signs_on_next_line = false +ij_java_ternary_operation_wrap = off +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = off +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = never +ij_java_while_on_new_line = false +ij_java_wrap_comments = false +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_continuation_indent_size = 4 +ij_xml_align_attributes = false +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = false +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = normal +ij_xml_use_custom_settings = true + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.c,*.c++,*.cc,*.cp,*.cpp,*.cu,*.cuh,*.cxx,*.h,*.h++,*.hh,*.hp,*.hpp,*.hxx,*.i,*.icc,*.ii,*.inl,*.ino,*.ipp,*.m,*.mm,*.pch,*.tcc,*.tpp}] +ij_c_add_brief_tag = false +ij_c_add_getter_prefix = true +ij_c_add_setter_prefix = true +ij_c_align_dictionary_pair_values = false +ij_c_align_group_field_declarations = false +ij_c_align_init_list_in_columns = true +ij_c_align_multiline_array_initializer_expression = true +ij_c_align_multiline_assignment = true +ij_c_align_multiline_binary_operation = true +ij_c_align_multiline_chained_methods = false +ij_c_align_multiline_for = true +ij_c_align_multiline_ternary_operation = true +ij_c_array_initializer_comma_on_next_line = false +ij_c_array_initializer_new_line_after_left_brace = false +ij_c_array_initializer_right_brace_on_new_line = false +ij_c_array_initializer_wrap = normal +ij_c_assignment_wrap = off +ij_c_binary_operation_sign_on_next_line = false +ij_c_binary_operation_wrap = normal +ij_c_blank_lines_after_class_header = 0 +ij_c_blank_lines_after_imports = 1 +ij_c_blank_lines_around_class = 1 +ij_c_blank_lines_around_field = 0 +ij_c_blank_lines_around_field_in_interface = 0 +ij_c_blank_lines_around_method = 1 +ij_c_blank_lines_around_method_in_interface = 1 +ij_c_blank_lines_around_namespace = 0 +ij_c_blank_lines_around_properties_in_declaration = 0 +ij_c_blank_lines_around_properties_in_interface = 0 +ij_c_blank_lines_before_imports = 1 +ij_c_blank_lines_before_method_body = 0 +ij_c_block_brace_placement = end_of_line +ij_c_block_brace_style = end_of_line +ij_c_block_comment_at_first_column = true +ij_c_catch_on_new_line = false +ij_c_class_brace_style = end_of_line +ij_c_class_constructor_init_list_align_multiline = true +ij_c_class_constructor_init_list_comma_on_next_line = false +ij_c_class_constructor_init_list_new_line_after_colon = never +ij_c_class_constructor_init_list_new_line_before_colon = if_long +ij_c_class_constructor_init_list_wrap = normal +ij_c_copy_is_deep = false +ij_c_create_interface_for_categories = true +ij_c_declare_generated_methods = true +ij_c_description_include_member_names = true +ij_c_discharged_short_ternary_operator = false +ij_c_do_not_add_breaks = false +ij_c_do_while_brace_force = never +ij_c_else_on_new_line = false +ij_c_enum_constants_comma_on_next_line = false +ij_c_enum_constants_wrap = on_every_item +ij_c_for_brace_force = never +ij_c_for_statement_new_line_after_left_paren = false +ij_c_for_statement_right_paren_on_new_line = false +ij_c_for_statement_wrap = off +ij_c_function_brace_placement = end_of_line +ij_c_function_call_arguments_align_multiline = true +ij_c_function_call_arguments_align_multiline_pars = false +ij_c_function_call_arguments_comma_on_next_line = false +ij_c_function_call_arguments_new_line_after_lpar = false +ij_c_function_call_arguments_new_line_before_rpar = false +ij_c_function_call_arguments_wrap = normal +ij_c_function_non_top_after_return_type_wrap = normal +ij_c_function_parameters_align_multiline = true +ij_c_function_parameters_align_multiline_pars = false +ij_c_function_parameters_comma_on_next_line = false +ij_c_function_parameters_new_line_after_lpar = false +ij_c_function_parameters_new_line_before_rpar = false +ij_c_function_parameters_wrap = normal +ij_c_function_top_after_return_type_wrap = normal +ij_c_generate_additional_eq_operators = true +ij_c_generate_additional_rel_operators = true +ij_c_generate_class_constructor = true +ij_c_generate_comparison_operators_use_std_tie = false +ij_c_generate_instance_variables_for_properties = ask +ij_c_generate_operators_as_members = true +ij_c_header_guard_style_pattern = ${PROJECT_NAME}_${FILE_NAME}_${EXT} +ij_c_if_brace_force = never +ij_c_in_line_short_ternary_operator = true +ij_c_indent_block_comment = true +ij_c_indent_c_struct_members = 4 +ij_c_indent_case_from_switch = true +ij_c_indent_class_members = 4 +ij_c_indent_directive_as_code = false +ij_c_indent_implementation_members = 0 +ij_c_indent_inside_code_block = 4 +ij_c_indent_interface_members = 0 +ij_c_indent_interface_members_except_ivars_block = false +ij_c_indent_namespace_members = 4 +ij_c_indent_preprocessor_directive = 0 +ij_c_indent_visibility_keywords = 0 +ij_c_insert_override = true +ij_c_insert_virtual_with_override = false +ij_c_introduce_auto_vars = false +ij_c_introduce_const_params = false +ij_c_introduce_const_vars = false +ij_c_introduce_generate_property = false +ij_c_introduce_generate_synthesize = true +ij_c_introduce_globals_to_header = true +ij_c_introduce_prop_to_private_category = false +ij_c_introduce_static_consts = true +ij_c_introduce_use_ns_types = false +ij_c_ivars_prefix = _ +ij_c_keep_blank_lines_before_end = 2 +ij_c_keep_blank_lines_before_right_brace = 2 +ij_c_keep_blank_lines_in_code = 2 +ij_c_keep_blank_lines_in_declarations = 2 +ij_c_keep_case_expressions_in_one_line = false +ij_c_keep_control_statement_in_one_line = true +ij_c_keep_directive_at_first_column = true +ij_c_keep_first_column_comment = true +ij_c_keep_line_breaks = true +ij_c_keep_nested_namespaces_in_one_line = false +ij_c_keep_simple_blocks_in_one_line = true +ij_c_keep_simple_methods_in_one_line = true +ij_c_keep_structures_in_one_line = false +ij_c_lambda_capture_list_align_multiline = false +ij_c_lambda_capture_list_align_multiline_bracket = false +ij_c_lambda_capture_list_comma_on_next_line = false +ij_c_lambda_capture_list_new_line_after_lbracket = false +ij_c_lambda_capture_list_new_line_before_rbracket = false +ij_c_lambda_capture_list_wrap = off +ij_c_line_comment_add_space = false +ij_c_line_comment_at_first_column = true +ij_c_method_brace_placement = end_of_line +ij_c_method_call_arguments_align_by_colons = true +ij_c_method_call_arguments_align_multiline = false +ij_c_method_call_arguments_special_dictionary_pairs_treatment = true +ij_c_method_call_arguments_wrap = off +ij_c_method_call_chain_wrap = off +ij_c_method_parameters_align_by_colons = true +ij_c_method_parameters_align_multiline = false +ij_c_method_parameters_wrap = off +ij_c_namespace_brace_placement = end_of_line +ij_c_parentheses_expression_new_line_after_left_paren = false +ij_c_parentheses_expression_right_paren_on_new_line = false +ij_c_place_assignment_sign_on_next_line = false +ij_c_property_nonatomic = true +ij_c_put_ivars_to_implementation = true +ij_c_refactor_compatibility_aliases_and_classes = true +ij_c_refactor_properties_and_ivars = true +ij_c_release_style = ivar +ij_c_retain_object_parameters_in_constructor = true +ij_c_semicolon_after_method_signature = false +ij_c_shift_operation_align_multiline = true +ij_c_shift_operation_wrap = normal +ij_c_show_non_virtual_functions = false +ij_c_space_after_colon = true +ij_c_space_after_colon_in_selector = false +ij_c_space_after_comma = true +ij_c_space_after_cup_in_blocks = false +ij_c_space_after_dictionary_literal_colon = true +ij_c_space_after_for_semicolon = true +ij_c_space_after_init_list_colon = true +ij_c_space_after_method_parameter_type_parentheses = false +ij_c_space_after_method_return_type_parentheses = false +ij_c_space_after_pointer_in_declaration = false +ij_c_space_after_quest = true +ij_c_space_after_reference_in_declaration = false +ij_c_space_after_reference_in_rvalue = false +ij_c_space_after_structures_rbrace = true +ij_c_space_after_superclass_colon = true +ij_c_space_after_type_cast = true +ij_c_space_after_visibility_sign_in_method_declaration = true +ij_c_space_before_autorelease_pool_lbrace = true +ij_c_space_before_catch_keyword = true +ij_c_space_before_catch_left_brace = true +ij_c_space_before_catch_parentheses = true +ij_c_space_before_category_parentheses = true +ij_c_space_before_chained_send_message = true +ij_c_space_before_class_left_brace = true +ij_c_space_before_colon = true +ij_c_space_before_comma = false +ij_c_space_before_dictionary_literal_colon = false +ij_c_space_before_do_left_brace = true +ij_c_space_before_else_keyword = true +ij_c_space_before_else_left_brace = true +ij_c_space_before_for_left_brace = true +ij_c_space_before_for_parentheses = true +ij_c_space_before_for_semicolon = false +ij_c_space_before_if_left_brace = true +ij_c_space_before_if_parentheses = true +ij_c_space_before_init_list = false +ij_c_space_before_init_list_colon = true +ij_c_space_before_method_call_parentheses = false +ij_c_space_before_method_left_brace = true +ij_c_space_before_method_parentheses = false +ij_c_space_before_namespace_lbrace = true +ij_c_space_before_pointer_in_declaration = true +ij_c_space_before_property_attributes_parentheses = false +ij_c_space_before_protocols_brackets = true +ij_c_space_before_quest = true +ij_c_space_before_reference_in_declaration = true +ij_c_space_before_superclass_colon = true +ij_c_space_before_switch_left_brace = true +ij_c_space_before_switch_parentheses = true +ij_c_space_before_template_call_lt = false +ij_c_space_before_template_declaration_lt = false +ij_c_space_before_try_left_brace = true +ij_c_space_before_while_keyword = true +ij_c_space_before_while_left_brace = true +ij_c_space_before_while_parentheses = true +ij_c_space_between_adjacent_brackets = false +ij_c_space_between_operator_and_punctuator = false +ij_c_space_within_empty_array_initializer_braces = false +ij_c_spaces_around_additive_operators = true +ij_c_spaces_around_assignment_operators = true +ij_c_spaces_around_bitwise_operators = true +ij_c_spaces_around_equality_operators = true +ij_c_spaces_around_lambda_arrow = true +ij_c_spaces_around_logical_operators = true +ij_c_spaces_around_multiplicative_operators = true +ij_c_spaces_around_pm_operators = false +ij_c_spaces_around_relational_operators = true +ij_c_spaces_around_shift_operators = true +ij_c_spaces_around_unary_operator = false +ij_c_spaces_within_array_initializer_braces = false +ij_c_spaces_within_braces = true +ij_c_spaces_within_brackets = false +ij_c_spaces_within_cast_parentheses = false +ij_c_spaces_within_catch_parentheses = false +ij_c_spaces_within_category_parentheses = false +ij_c_spaces_within_empty_braces = false +ij_c_spaces_within_empty_function_call_parentheses = false +ij_c_spaces_within_empty_function_declaration_parentheses = false +ij_c_spaces_within_empty_lambda_capture_list_bracket = false +ij_c_spaces_within_empty_template_call_ltgt = false +ij_c_spaces_within_empty_template_declaration_ltgt = false +ij_c_spaces_within_for_parentheses = false +ij_c_spaces_within_function_call_parentheses = false +ij_c_spaces_within_function_declaration_parentheses = false +ij_c_spaces_within_if_parentheses = false +ij_c_spaces_within_lambda_capture_list_bracket = false +ij_c_spaces_within_method_parameter_type_parentheses = false +ij_c_spaces_within_method_return_type_parentheses = false +ij_c_spaces_within_parentheses = false +ij_c_spaces_within_property_attributes_parentheses = false +ij_c_spaces_within_protocols_brackets = false +ij_c_spaces_within_send_message_brackets = false +ij_c_spaces_within_switch_parentheses = false +ij_c_spaces_within_template_call_ltgt = false +ij_c_spaces_within_template_declaration_ltgt = false +ij_c_spaces_within_template_double_gt = true +ij_c_spaces_within_while_parentheses = false +ij_c_special_else_if_treatment = true +ij_c_superclass_list_after_colon = never +ij_c_superclass_list_align_multiline = true +ij_c_superclass_list_before_colon = if_long +ij_c_superclass_list_comma_on_next_line = false +ij_c_superclass_list_wrap = on_every_item +ij_c_tag_prefix_of_block_comment = at +ij_c_tag_prefix_of_line_comment = back_slash +ij_c_template_call_arguments_align_multiline = false +ij_c_template_call_arguments_align_multiline_pars = false +ij_c_template_call_arguments_comma_on_next_line = false +ij_c_template_call_arguments_new_line_after_lt = false +ij_c_template_call_arguments_new_line_before_gt = false +ij_c_template_call_arguments_wrap = off +ij_c_template_declaration_function_body_indent = false +ij_c_template_declaration_function_wrap = split_into_lines +ij_c_template_declaration_struct_body_indent = false +ij_c_template_declaration_struct_wrap = split_into_lines +ij_c_template_parameters_align_multiline = false +ij_c_template_parameters_align_multiline_pars = false +ij_c_template_parameters_comma_on_next_line = false +ij_c_template_parameters_new_line_after_lt = false +ij_c_template_parameters_new_line_before_gt = false +ij_c_template_parameters_wrap = off +ij_c_ternary_operation_signs_on_next_line = true +ij_c_ternary_operation_wrap = normal +ij_c_type_qualifiers_placement = before +ij_c_use_modern_casts = true +ij_c_use_setters_in_constructor = true +ij_c_while_brace_force = never +ij_c_while_on_new_line = false +ij_c_wrap_property_declaration = off + +[{*.cmake,CMakeLists.txt}] +ij_cmake_align_multiline_parameters_in_calls = false +ij_cmake_force_commands_case = 2 +ij_cmake_keep_blank_lines_in_code = 2 +ij_cmake_space_before_for_parentheses = true +ij_cmake_space_before_if_parentheses = true +ij_cmake_space_before_method_call_parentheses = false +ij_cmake_space_before_method_parentheses = false +ij_cmake_space_before_while_parentheses = true +ij_cmake_spaces_within_for_parentheses = false +ij_cmake_spaces_within_if_parentheses = false +ij_cmake_spaces_within_method_call_parentheses = false +ij_cmake_spaces_within_method_parentheses = false +ij_cmake_spaces_within_while_parentheses = false + +[{*.gant,*.gradle,*.groovy,*.gy}] +ij_groovy_align_group_field_declarations = false +ij_groovy_align_multiline_array_initializer_expression = false +ij_groovy_align_multiline_assignment = false +ij_groovy_align_multiline_binary_operation = false +ij_groovy_align_multiline_chained_methods = false +ij_groovy_align_multiline_extends_list = false +ij_groovy_align_multiline_for = true +ij_groovy_align_multiline_list_or_map = true +ij_groovy_align_multiline_method_parentheses = false +ij_groovy_align_multiline_parameters = true +ij_groovy_align_multiline_parameters_in_calls = false +ij_groovy_align_multiline_resources = true +ij_groovy_align_multiline_ternary_operation = false +ij_groovy_align_multiline_throws_list = false +ij_groovy_align_named_args_in_map = true +ij_groovy_align_throws_keyword = false +ij_groovy_array_initializer_new_line_after_left_brace = false +ij_groovy_array_initializer_right_brace_on_new_line = false +ij_groovy_array_initializer_wrap = off +ij_groovy_assert_statement_wrap = off +ij_groovy_assignment_wrap = off +ij_groovy_binary_operation_wrap = off +ij_groovy_blank_lines_after_class_header = 0 +ij_groovy_blank_lines_after_imports = 1 +ij_groovy_blank_lines_after_package = 1 +ij_groovy_blank_lines_around_class = 1 +ij_groovy_blank_lines_around_field = 0 +ij_groovy_blank_lines_around_field_in_interface = 0 +ij_groovy_blank_lines_around_method = 1 +ij_groovy_blank_lines_around_method_in_interface = 1 +ij_groovy_blank_lines_before_imports = 1 +ij_groovy_blank_lines_before_method_body = 0 +ij_groovy_blank_lines_before_package = 0 +ij_groovy_block_brace_style = end_of_line +ij_groovy_block_comment_at_first_column = true +ij_groovy_call_parameters_new_line_after_left_paren = false +ij_groovy_call_parameters_right_paren_on_new_line = false +ij_groovy_call_parameters_wrap = off +ij_groovy_catch_on_new_line = false +ij_groovy_class_annotation_wrap = split_into_lines +ij_groovy_class_brace_style = end_of_line +ij_groovy_class_count_to_use_import_on_demand = 5 +ij_groovy_do_while_brace_force = never +ij_groovy_else_on_new_line = false +ij_groovy_enum_constants_wrap = off +ij_groovy_extends_keyword_wrap = off +ij_groovy_extends_list_wrap = off +ij_groovy_field_annotation_wrap = split_into_lines +ij_groovy_finally_on_new_line = false +ij_groovy_for_brace_force = never +ij_groovy_for_statement_new_line_after_left_paren = false +ij_groovy_for_statement_right_paren_on_new_line = false +ij_groovy_for_statement_wrap = off +ij_groovy_if_brace_force = never +ij_groovy_import_annotation_wrap = 2 +ij_groovy_imports_layout = *, |, javax.**, java.**, |, $* +ij_groovy_indent_case_from_switch = true +ij_groovy_indent_label_blocks = true +ij_groovy_insert_inner_class_imports = false +ij_groovy_keep_blank_lines_before_right_brace = 2 +ij_groovy_keep_blank_lines_in_code = 2 +ij_groovy_keep_blank_lines_in_declarations = 2 +ij_groovy_keep_control_statement_in_one_line = true +ij_groovy_keep_first_column_comment = true +ij_groovy_keep_indents_on_empty_lines = false +ij_groovy_keep_line_breaks = true +ij_groovy_keep_multiple_expressions_in_one_line = false +ij_groovy_keep_simple_blocks_in_one_line = false +ij_groovy_keep_simple_classes_in_one_line = true +ij_groovy_keep_simple_lambdas_in_one_line = true +ij_groovy_keep_simple_methods_in_one_line = true +ij_groovy_label_indent_absolute = false +ij_groovy_label_indent_size = 0 +ij_groovy_lambda_brace_style = end_of_line +ij_groovy_layout_static_imports_separately = true +ij_groovy_line_comment_add_space = false +ij_groovy_line_comment_at_first_column = true +ij_groovy_method_annotation_wrap = split_into_lines +ij_groovy_method_brace_style = end_of_line +ij_groovy_method_call_chain_wrap = off +ij_groovy_method_parameters_new_line_after_left_paren = false +ij_groovy_method_parameters_right_paren_on_new_line = false +ij_groovy_method_parameters_wrap = off +ij_groovy_modifier_list_wrap = false +ij_groovy_names_count_to_use_import_on_demand = 3 +ij_groovy_parameter_annotation_wrap = off +ij_groovy_parentheses_expression_new_line_after_left_paren = false +ij_groovy_parentheses_expression_right_paren_on_new_line = false +ij_groovy_prefer_parameters_wrap = false +ij_groovy_resource_list_new_line_after_left_paren = false +ij_groovy_resource_list_right_paren_on_new_line = false +ij_groovy_resource_list_wrap = off +ij_groovy_space_after_assert_separator = true +ij_groovy_space_after_colon = true +ij_groovy_space_after_comma = true +ij_groovy_space_after_comma_in_type_arguments = true +ij_groovy_space_after_for_semicolon = true +ij_groovy_space_after_quest = true +ij_groovy_space_after_type_cast = true +ij_groovy_space_before_annotation_parameter_list = false +ij_groovy_space_before_array_initializer_left_brace = false +ij_groovy_space_before_assert_separator = false +ij_groovy_space_before_catch_keyword = true +ij_groovy_space_before_catch_left_brace = true +ij_groovy_space_before_catch_parentheses = true +ij_groovy_space_before_class_left_brace = true +ij_groovy_space_before_closure_left_brace = true +ij_groovy_space_before_colon = true +ij_groovy_space_before_comma = false +ij_groovy_space_before_do_left_brace = true +ij_groovy_space_before_else_keyword = true +ij_groovy_space_before_else_left_brace = true +ij_groovy_space_before_finally_keyword = true +ij_groovy_space_before_finally_left_brace = true +ij_groovy_space_before_for_left_brace = true +ij_groovy_space_before_for_parentheses = true +ij_groovy_space_before_for_semicolon = false +ij_groovy_space_before_if_left_brace = true +ij_groovy_space_before_if_parentheses = true +ij_groovy_space_before_method_call_parentheses = false +ij_groovy_space_before_method_left_brace = true +ij_groovy_space_before_method_parentheses = false +ij_groovy_space_before_quest = true +ij_groovy_space_before_switch_left_brace = true +ij_groovy_space_before_switch_parentheses = true +ij_groovy_space_before_synchronized_left_brace = true +ij_groovy_space_before_synchronized_parentheses = true +ij_groovy_space_before_try_left_brace = true +ij_groovy_space_before_try_parentheses = true +ij_groovy_space_before_while_keyword = true +ij_groovy_space_before_while_left_brace = true +ij_groovy_space_before_while_parentheses = true +ij_groovy_space_in_named_argument = true +ij_groovy_space_in_named_argument_before_colon = false +ij_groovy_space_within_empty_array_initializer_braces = false +ij_groovy_space_within_empty_method_call_parentheses = false +ij_groovy_spaces_around_additive_operators = true +ij_groovy_spaces_around_assignment_operators = true +ij_groovy_spaces_around_bitwise_operators = true +ij_groovy_spaces_around_equality_operators = true +ij_groovy_spaces_around_lambda_arrow = true +ij_groovy_spaces_around_logical_operators = true +ij_groovy_spaces_around_multiplicative_operators = true +ij_groovy_spaces_around_regex_operators = true +ij_groovy_spaces_around_relational_operators = true +ij_groovy_spaces_around_shift_operators = true +ij_groovy_spaces_within_annotation_parentheses = false +ij_groovy_spaces_within_array_initializer_braces = false +ij_groovy_spaces_within_braces = true +ij_groovy_spaces_within_brackets = false +ij_groovy_spaces_within_cast_parentheses = false +ij_groovy_spaces_within_catch_parentheses = false +ij_groovy_spaces_within_for_parentheses = false +ij_groovy_spaces_within_gstring_injection_braces = false +ij_groovy_spaces_within_if_parentheses = false +ij_groovy_spaces_within_list_or_map = false +ij_groovy_spaces_within_method_call_parentheses = false +ij_groovy_spaces_within_method_parentheses = false +ij_groovy_spaces_within_parentheses = false +ij_groovy_spaces_within_switch_parentheses = false +ij_groovy_spaces_within_synchronized_parentheses = false +ij_groovy_spaces_within_try_parentheses = false +ij_groovy_spaces_within_tuple_expression = false +ij_groovy_spaces_within_while_parentheses = false +ij_groovy_special_else_if_treatment = true +ij_groovy_ternary_operation_wrap = off +ij_groovy_throws_keyword_wrap = off +ij_groovy_throws_list_wrap = off +ij_groovy_use_flying_geese_braces = false +ij_groovy_use_fq_class_names = false +ij_groovy_use_fq_class_names_in_javadoc = true +ij_groovy_use_relative_indents = false +ij_groovy_use_single_class_imports = true +ij_groovy_variable_annotation_wrap = off +ij_groovy_while_brace_force = never +ij_groovy_while_on_new_line = false +ij_groovy_wrap_long_lines = false + +[{*.gradle.kts,*.kt,*.kts,*.main.kts}] +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_assignment_wrap = off +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = false +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = off +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = off +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = true +ij_kotlin_continuation_indent_in_if_conditions = true +ij_kotlin_continuation_indent_in_parameter_lists = true +ij_kotlin_continuation_indent_in_supertype_lists = true +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = off +ij_kotlin_field_annotation_wrap = normal +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *, java.**, javax.**, kotlin.**, ^ +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 0 +ij_kotlin_keep_blank_lines_in_code = 1 +ij_kotlin_keep_blank_lines_in_declarations = 1 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = off +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = off +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = kotlinx.android.synthetic.** +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_use_custom_formatting_for_modifiers = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 0 +ij_kotlin_wrap_first_method_in_call_chain = false + +[{*.har,*.json}] +indent_size = 2 +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = true +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.htm,*.html,*.sht,*.shtm,*.shtml}] +ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3 +ij_html_align_attributes = true +ij_html_align_text = false +ij_html_attribute_wrap = normal +ij_html_block_comment_at_first_column = true +ij_html_do_not_align_children_of_min_lines = 0 +ij_html_do_not_break_if_inline_tags = title, h1, h2, h3, h4, h5, h6, p +ij_html_do_not_indent_children_of_tags = html, body, thead, tbody, tfoot +ij_html_enforce_quotes = false +ij_html_inline_tags = a, abbr, acronym, b, basefont, bdo, big, br, cite, cite, code, dfn, em, font, i, img, input, kbd, label, q, s, samp, select, small, span, strike, strong, sub, sup, textarea, tt, u, var +ij_html_keep_blank_lines = 2 +ij_html_keep_indents_on_empty_lines = false +ij_html_keep_line_breaks = true +ij_html_keep_line_breaks_in_text = true +ij_html_keep_whitespaces = false +ij_html_keep_whitespaces_inside = span, pre, textarea +ij_html_line_comment_at_first_column = true +ij_html_new_line_after_last_attribute = never +ij_html_new_line_before_first_attribute = never +ij_html_quote_style = double +ij_html_remove_new_line_before_tags = br +ij_html_space_after_tag_name = false +ij_html_space_around_equality_in_attribute = false +ij_html_space_inside_empty_tag = false +ij_html_text_wrap = normal +ij_html_uniform_ident = false + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..0542767eff --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000000..746e26a227 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,86 @@ +name: Bug report for the Element X Android app +description: Report any issues that you have found with the Element X app. Please [check open issues](https://github.com/vector-im/element-x-android/issues) first, in case it has already been reported. +labels: [T-Defect] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + Please report security issues by email to security@matrix.org + - type: textarea + id: reproduction-steps + attributes: + label: Steps to reproduce + description: Please attach screenshots, videos or logs if you can. + placeholder: Tell us what you see! + value: | + 1. Where are you starting? What can you see? + 2. What do you click? + 3. More steps… + validations: + required: true + - type: textarea + id: result + attributes: + label: Outcome + placeholder: Tell us what went wrong + value: | + #### What did you expect? + + #### What happened instead? + validations: + required: true + - type: input + id: device + attributes: + label: Your phone model + placeholder: e.g. Samsung S6 + validations: + required: false + - type: input + id: os + attributes: + label: Operating system version + placeholder: e.g. Android 10.0 + validations: + required: false + - type: input + id: version + attributes: + label: Application version and app store + description: You can find the version information in Settings -> Help & About. + placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid + validations: + required: false + - type: input + id: homeserver + attributes: + label: Homeserver + description: | + Which server is your account registered on? If it is a local or non-public homeserver, please tell us what is the homeserver implementation (ex: Synapse/Dendrite/etc.) and the version. + placeholder: e.g. matrix.org or Synapse 1.50.0rc1 + validations: + required: false + - type: dropdown + id: rageshake + attributes: + label: Will you send logs? + description: | + Did you know that you can shake your phone to submit logs for this issue? Trigger the defect, then shake your phone and you will see a popup asking if you would like to open the bug report screen. Click YES, and describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers. + options: + - 'Yes' + - 'No' + validations: + required: true + - type: dropdown + id: pr + attributes: + label: Are you willing to provide a PR? + description: | + Providing a PR can drastically speed up the process of fixing this bug. Don't worry, it's still OK to answer 'No' :). + options: + - 'Yes' + - 'No' + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 0000000000..0e51d5155e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,47 @@ +name: Enhancement request +description: Do you have a suggestion or feature request? +labels: [T-Enhancement] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to propose an enhancement to an existing feature. If you would like to propose a new feature or a major cross-platform change, please [start a discussion here](https://github.com/vector-im/element-meta/discussions/new?category=ideas). + - type: textarea + id: usecase + attributes: + label: Your use case + description: Please feel welcome to include screenshots or mock ups. + placeholder: Tell us what you would like to do! + value: | + #### What would you like to do? + + #### Why would you like to do it? + + #### How would you like to achieve it? + validations: + required: true + - type: textarea + id: alternative + attributes: + label: Have you considered any alternatives? + placeholder: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: Additional context + placeholder: Is there anything else you'd like to add? + validations: + required: false + - type: dropdown + id: pr + attributes: + label: Are you willing to provide a PR? + description: | + Don't worry, it's still OK to answer 'No' :). + options: + - 'Yes' + - 'No' + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/story.yml b/.github/ISSUE_TEMPLATE/story.yml new file mode 100644 index 0000000000..436b92b507 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/story.yml @@ -0,0 +1,35 @@ +name: User story issue +description: Second-level planning issue template. A story should take about a week or a sprint to finish. +title: "[Story] " +labels: [T-Story] + +body: +- type: textarea + attributes: + label: Story + description: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day. + value: | + As a user… + I want to… + so that I can… + + ## Scope + <!--These should be a list of technical tasks which take ½-1 day to complete--> + ```[tasklist] + ### Tasklist + - [ ] Task 1 + ``` + + - [ ] QA signoff on completion + - [ ] Design signoff on completion + - [ ] Product signoff on completion + + + ## Stretch goals + None at this time + <!--or add a tasklist--> + + ## Out of scope + - + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000000..431c018fdd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,57 @@ +<!-- Please read [CONTRIBUTING.md](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md) before submitting your pull request --> + +## Type of change + +- [ ] Feature +- [ ] Bugfix +- [ ] Technical +- [ ] Other : + +## Content + +<!-- Describe shortly what has been changed --> + +## Motivation and context + +<!-- Provide link to the corresponding issue if applicable or explain the context --> + +## Screenshots / GIFs + +<!-- Only if UI have been changed +You can use a table like this to show screenshots comparison. +Uncomment this markdown table below and edit the last line `|||`: +|copy screenshot of before here|copy screenshot of after here| +--> + +<!-- +|Before|After| +|-|-| +||| + --> + +## Tests + +<!-- Explain how you tested your development --> + +- Step 1 +- Step 2 +- Step ... + +## Tested devices + +- [ ] Physical +- [ ] Emulator +- OS version(s): + +## Checklist + +<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. --> + +- [ ] Changes has been tested on an Android device or Android emulator with API 21 +- [ ] UI change has been tested on both light and dark themes +- [ ] Accessibility has been taken into account. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#accessibility +- [ ] Pull request is based on the develop branch +- [ ] Pull request includes a new file under ./changelog.d. See https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog +- [ ] Pull request includes screenshots or videos if containing UI changes +- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off) +- [ ] You've made a self review of your PR diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..29542e98c6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# 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://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Updates for Github Actions used in the repo + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + reviewers: + - "vector-im/element-x-android-reviewers" + # Updates for Gradle dependencies used in the app + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 200 + ignore: + - dependency-name: "*" + reviewers: + - "vector-im/element-x-android-reviewers" diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000000..b3d57b233b --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,34 @@ +{ + "$schema" : "https://docs.renovatebot.com/renovate-schema.json", + "extends" : [ + "config:base" + ], + "labels" : [ + "dependencies" + ], + "ignoreDeps" : [ + "string:app_name" + ], + "packageRules" : [ + { + "matchPackagePatterns" : [ + "^org.jetbrains.kotlin", + "^com.google.devtools.ksp", + "^androidx.compose.compiler" + ], + "groupName" : "kotlin" + }, + { + "matchPackageNames" : [ + "org.jetbrains.kotlinx.kover" + ], + "enabled" : false + }, + { + "matchPackagePatterns" : [ + "^org.maplibre" + ], + "versioning" : "semver" + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..7d895d0fda --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,77 @@ +name: APK Build + +on: + workflow_dispatch: + pull_request: { } + push: + branches: [ main, develop ] + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon + +jobs: + debug: + name: Build debug APKs + runs-on: ubuntu-latest + if: github.ref != 'refs/heads/main' + strategy: + fail-fast: false + # Allow all jobs on develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}', github.sha) || format('build-debug-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v3 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/gradle-build-action@v2.6.1 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Assemble debug APK + env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} + run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES + - name: Upload debug APKs + uses: actions/upload-artifact@v3 + with: + name: elementx-debug + path: | + app/build/outputs/apk/debug/*.apk + - uses: rnkdsh/action-upload-diawi@v1.5.1 + id: diawi + # Do not fail the whole build if Diawi upload fails + continue-on-error: true + env: + token: ${{ secrets.DIAWI_TOKEN }} + if: ${{ github.event_name == 'pull_request' && env.token != '' }} + with: + token: ${{ env.token }} + file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk + - name: Add or update PR comment with QR Code to download APK. + if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }} + uses: NejcZdovc/comment-pr@v2 + with: + message: | + :iphone: Scan the QR code below to install the build (arm64 only) for this PR. + ![QR code](${{ steps.diawi.outputs['qrcode'] }}) + If you can't scan the QR code you can install the build via this link: ${{ steps.diawi.outputs['url'] }} + # Enables to identify and update existing Ad-hoc release message on new commit in the PR + identifier: "GITHUB_COMMENT_QR_CODE" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Compile release sources + run: ./gradlew compileReleaseSources $CI_GRADLE_ARG_PROPERTIES + - name: Compile nightly sources + run: ./gradlew compileNightlySources $CI_GRADLE_ARG_PROPERTIES + - name: Compile samples minimal + run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 0000000000..223a273b68 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,20 @@ +name: Danger CI + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + name: Danger main check + steps: + - uses: actions/checkout@v3 + - run: | + npm install --save-dev @babel/plugin-transform-flow-strip-types + - name: Danger + uses: danger/danger-js@11.2.6 + with: + args: "--dangerfile ./tools/danger/dangerfile.js" + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + # Fallback for forks + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml new file mode 100644 index 0000000000..33a12d3c54 --- /dev/null +++ b/.github/workflows/gradle-wrapper-update.yml @@ -0,0 +1,18 @@ +name: Update Gradle Wrapper + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + update-gradle-wrapper: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@v1 + # Skip in forks + if: github.repository == 'vector-im/element-x-android' + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + target-branch: develop diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 0000000000..7b68c0077d --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,14 @@ +name: "Validate Gradle Wrapper" +on: + pull_request: { } + push: + branches: [ main, develop ] + +jobs: + validation: + name: "Validation" + runs-on: ubuntu-latest + # No concurrency required, this is a prerequisite to other actions and should run every time. + steps: + - uses: actions/checkout@v3 + - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml new file mode 100644 index 0000000000..0349e373bb --- /dev/null +++ b/.github/workflows/maestro.yml @@ -0,0 +1,57 @@ +name: Maestro + +# Run this flow only on pull request, and only when the pull request has been approved, to limit our usage of maestro cloud. +# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-a-workflow-when-a-pull-request-is-approved +on: + workflow_dispatch: + pull_request_review: + types: [submitted] + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon + +jobs: + maestro-cloud: + name: Maestro test suite + runs-on: ubuntu-latest + if: github.event.review.state == 'approved' || github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + # Allow one per PR. + concurrency: + group: ${{ format('maestro-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v3 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - uses: actions/setup-java@v3 + name: Use JDK 17 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Assemble debug APK + run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES + env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} + - name: Upload debug APKs + uses: actions/upload-artifact@v3 + with: + name: elementx-debug + path: | + app/build/outputs/apk/debug/*.apk + - uses: mobile-dev-inc/action-maestro-cloud@v1.4.1 + with: + api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} + app-file: app/build/outputs/apk/debug/app-universal-debug.apk + env: | + USERNAME=maestroelement + PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} + ROOM_NAME=MyRoom + INVITEE1_MXID=@maestroelement2:matrix.org + INVITEE2_MXID=@maestroelement3:matrix.org + APP_ID=io.element.android.x.debug diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000..95c2deb8eb --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,48 @@ +name: Build and release nightly APK + +on: + workflow_dispatch: + schedule: + # Every nights at 4 + - cron: "0 4 * * *" + +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon + +jobs: + nightly: + name: Build and publish nightly APK to Firebase + runs-on: ubuntu-latest + if: ${{ github.repository == 'vector-im/element-x-android' }} + steps: + - uses: actions/checkout@v3 + - name: Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Install towncrier + run: | + python3 -m pip install towncrier + - name: Prepare changelog file + run: | + mv towncrier.toml towncrier.toml.bak + sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml + rm towncrier.toml.bak + yes n | towncrier build --version nightly + - name: Build and upload Nightly APK + run: | + ./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES + env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} + ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }} + ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }} + ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }} + FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }} + - name: Additionally upload Nightly APK to browserstack for testing + continue-on-error: true # don't block anything by this upload failing (for now) + run: curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/nightly/app-universal-nightly.apk" -F "custom_id=element-x-android-nightly" + env: + BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }} + BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }} diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml new file mode 100644 index 0000000000..ce7b763ef1 --- /dev/null +++ b/.github/workflows/nightlyReports.yml @@ -0,0 +1,75 @@ +name: Nightly reports + +on: + workflow_dispatch: + schedule: + # Every nights at 5 + - cron: "0 5 * * *" + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 + +jobs: + nightlyReports: + name: Create kover report artifact and upload sonar result. + runs-on: ubuntu-latest + if: ${{ github.repository == 'vector-im/element-x-android' }} + steps: + - name: ⏬ Checkout with LFS + uses: nschloe/action-cached-lfs-checkout@v1.2.1 + + - name: Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + + - name: ⚙️ Run unit tests, debug and release + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + + - name: 📸 Run screenshot tests + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + + - name: 📈 Generate kover report and verify coverage + run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true + + - name: ✅ Upload kover report + if: always() + uses: actions/upload-artifact@v3 + with: + name: kover-results + path: | + **/build/reports/kover/merged + + - name: 🔊 Publish results to Sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} + if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} + run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES + + # Gradle dependency analysis using https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin + dependency-analysis: + name: Dependency analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/gradle-build-action@v2.6.1 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Dependency analysis + run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES + - name: Upload dependency analysis + if: always() + uses: actions/upload-artifact@v3 + with: + name: dependency-analysis + path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000000..94b8b7ff4e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,74 @@ +name: Code Quality Checks + +on: + workflow_dispatch: + pull_request: { } + push: + branches: [ main, develop ] + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn + +jobs: + checkScript: + name: Search for forbidden patterns + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Run code quality check suite + run: ./tools/check/check_code_quality.sh + + check: + name: Project Check Suite + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v3 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/gradle-build-action@v2.6.1 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run code quality check suite + run: ./gradlew runQualityChecks $CI_GRADLE_ARG_PROPERTIES + - name: Upload reports + if: always() + uses: actions/upload-artifact@v3 + with: + name: linting-report + path: | + */build/reports/**/*.* + - name: 🔊 Publish results to Sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }} + if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }} + run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES + - name: Prepare Danger + if: always() + run: | + npm install --save-dev @babel/core + npm install --save-dev @babel/plugin-transform-flow-strip-types + yarn add danger-plugin-lint-report --dev + - name: Danger lint + if: always() + uses: danger/danger-js@11.2.6 + with: + args: "--dangerfile ./tools/danger/dangerfile-lint.js" + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + # Fallback for forks + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml new file mode 100644 index 0000000000..d088b3ad94 --- /dev/null +++ b/.github/workflows/recordScreenshots.yml @@ -0,0 +1,35 @@ +name: Record screenshots + +on: + workflow_dispatch: + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + +jobs: + record: + name: Record screenshots on branch ${{ github.ref_name }} + runs-on: ubuntu-latest + + steps: + - name: ⏬ Checkout with LFS + uses: nschloe/action-cached-lfs-checkout@v1.2.1 + with: + persist-credentials: false + - name: ☕️ Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + # Add gradle cache, this should speed up the process + - name: Configure gradle + uses: gradle/gradle-build-action@v2.6.1 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Record screenshots + run: "./.github/workflows/scripts/recordScreenshots.sh" + env: + GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }} + diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh new file mode 100755 index 0000000000..792be75931 --- /dev/null +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +# +# Copyright (c) 2023 New Vector Ltd +# +# 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. +# + +set -e + +TOKEN=$GITHUB_TOKEN +REPO=$GITHUB_REPOSITORY + +SHORT=t:,r: +LONG=token:,repo: +OPTS=$(getopt -a -n recordScreenshots --options $SHORT --longoptions $LONG -- "$@") + +eval set -- "$OPTS" +while : +do + case "$1" in + -t | --token ) + TOKEN="$2" + shift 2 + ;; + -r | --repo ) + REPO="$2" + shift 2 + ;; + --) + shift; + break + ;; + *) + echo "Unexpected option: $1" + help + ;; + esac +done + +if [[ -z ${TOKEN} ]]; then + echo "No token specified, either set the env var GITHUB_TOKEN or use the --token option" + exit 1 +fi + +if [[ -z ${REPO} ]]; then + echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option" + exit 1 +fi + +echo "Deleting previous screenshots" +./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn + +echo "Record screenshots" +./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn + +echo "Committing changes" +git config user.name "ElementBot" +git config user.email "benoitm+elementbot@element.io" +git add -A +git commit -m "Update screenshots" + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +echo "Pushing changes" +git push "https://$TOKEN@github.com/$REPO.git" $BRANCH:refs/heads/$BRANCH +echo "Done!" diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml new file mode 100644 index 0000000000..3d63f8b542 --- /dev/null +++ b/.github/workflows/sync-localazy.yml @@ -0,0 +1,35 @@ +name: Sync Localazy +on: + workflow_dispatch: + schedule: + # At 00:00 on every Monday UTC + - cron: '0 0 * * 1' + +jobs: + sync-localazy: + runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'vector-im/element-x-android' + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Setup Localazy + run: | + curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg + echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list + sudo apt-get update && sudo apt-get install localazy + - name: Run Localazy script + run: ./tools/localazy/downloadStrings.sh --all + - name: Create Pull Request for Strings + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + commit-message: Sync Strings from Localazy + title: Sync Strings + body: | + - Update Strings from Localazy + branch: sync-localazy + base: develop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000000..04fd393e25 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,91 @@ +name: Test + +on: + workflow_dispatch: + pull_request: { } + push: + branches: [ main, develop ] + +# Enrich gradle.properties for CI/CD +env: + GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn + +jobs: + tests: + name: Runs unit tests + runs-on: ubuntu-latest + + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }} + cancel-in-progress: true + steps: + - name: ⏬ Checkout with LFS + uses: nschloe/action-cached-lfs-checkout@v1.2.1 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: ☕️ Use JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/gradle-build-action@v2.6.1 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + + - name: ⚙️ Run unit tests, debug and release + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + + - name: 📸 Run screenshot tests + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + + - name: 📈Generate kover report and verify coverage + run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true + + - name: 🚫 Upload kover failed coverage reports + if: failure() + uses: actions/upload-artifact@v3 + with: + name: kover-error-report + path: | + **/kover/merged/verification/errors.txt + + - name: 📸 Upload Screenshot test report + uses: actions/upload-artifact@v3 + if: always() + with: + name: reports + path: tests/uitests/build/reports/tests/testDebugUnitTest/ + retention-days: 5 + + - name: 🚫 Upload Screenshot failure differences on error + uses: actions/upload-artifact@v3 + if: failure() + with: + name: failures + path: tests/uitests/out/failures/ + retention-days: 5 + + - name: ✅ Upload kover report (disabled) + if: always() + run: echo "This is now done only once a day, see nightlyReports.yml" + + - name: 🚫 Upload test results on error + if: failure() + uses: actions/upload-artifact@v3 + with: + name: tests-and-screenshot-tests-results + path: | + **/out/failures/ + **/build/reports/tests/*UnitTest/ + + # https://github.com/codecov/codecov-action + - name: ☂️ Upload coverage reports to codecov + if: always() + uses: codecov/codecov-action@v3 + # with: + # files: build/reports/kover/merged/xml/report.xml diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml new file mode 100644 index 0000000000..1232b11d92 --- /dev/null +++ b/.github/workflows/triage-incoming.yml @@ -0,0 +1,14 @@ +name: Move new issues onto issue triage board v2 + +on: + issues: + types: [ opened ] + +jobs: + triage-new-issues: + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/91 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml new file mode 100644 index 0000000000..5f7e6cc6ec --- /dev/null +++ b/.github/workflows/triage-labelled.yml @@ -0,0 +1,83 @@ +name: Move labelled issues to correct boards and columns + +on: + issues: + types: [labeled] + +jobs: + move_element_x_issues: + name: ElementX issues to ElementX project board + runs-on: ubuntu-latest + # Skip in forks + if: > + github.repository == 'vector-im/element-x-android' + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/43 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + + move_needs_info: + name: Move triaged needs info issues on board + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@main + id: addItem + with: + project-url: https://github.com/orgs/vector-im/projects/91 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + labeled: X-Needs-Info + - name: Print itemId + run: echo ${{ steps.addItem.outputs.itemId }} + - uses: kalgurn/update-project-item-status@main + if: ${{ steps.addItem.outputs.itemId }} + with: + project-url: https://github.com/orgs/vector-im/projects/91 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + item-id: ${{ steps.addItem.outputs.itemId }} + status: "Needs info" + + ex_plorers: + name: Add labelled issues to X-Plorer project + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'Team: Element X Feature') + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/73 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + + verticals_feature: + name: Add labelled issues to Verticals Feature project + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'Team: Verticals Feature') + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/57 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + + qa: + name: Add labelled issues to QA project + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'Team: QA') || + contains(github.event.issue.labels.*.name, 'X-Needs-Signoff') + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/69 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} + + signoff: + name: Add labelled issues to signoff project + runs-on: ubuntu-latest + if: > + contains(github.event.issue.labels.*.name, 'X-Needs-Signoff') + steps: + - uses: actions/add-to-project@main + with: + project-url: https://github.com/orgs/vector-im/projects/89 + github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml new file mode 100644 index 0000000000..25fe50359c --- /dev/null +++ b/.github/workflows/validate-lfs.yml @@ -0,0 +1,13 @@ +name: Validate Git LFS + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + name: Validate + steps: + - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + + - run: | + ./tools/git/validate_lfs.sh diff --git a/.gitignore b/.gitignore index 56cc6425e0..70599626ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Built application files *.apk -*.aar *.ap_ *.aab @@ -38,17 +37,25 @@ captures/ # IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml +.idea/.name .idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -# Android Studio 3 in .gitignore file. -.idea/caches +.idea/compiler.xml +.idea/deploymentTargetDropDown.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/misc.xml .idea/modules.xml # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml +.idea/tasks.xml +.idea/workspace.xml +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/inspectionProfiles +# Shelved changes in the IDE +.idea/shelf +.idea/sonarlint # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. @@ -83,3 +90,6 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +/tmp +.DS_Store diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..cdef735570 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,124 @@ +<component name="ProjectCodeStyleConfiguration"> + <code_scheme name="Project" version="173"> + <JetCodeStyleSettings> + <option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" /> + <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> + </JetCodeStyleSettings> + <codeStyleSettings language="XML"> + <option name="FORCE_REARRANGE_MODE" value="1" /> + <indentOptions> + <option name="CONTINUATION_INDENT_SIZE" value="4" /> + </indentOptions> + <arrangement> + <rules> + <section> + <rule> + <match> + <AND> + <NAME>xmlns:android</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>^$</XML_NAMESPACE> + </AND> + </match> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>xmlns:.*</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>^$</XML_NAMESPACE> + </AND> + </match> + <order>BY_NAME</order> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>.*:id</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE> + </AND> + </match> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>.*:name</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE> + </AND> + </match> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>name</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>^$</XML_NAMESPACE> + </AND> + </match> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>style</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>^$</XML_NAMESPACE> + </AND> + </match> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>.*</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>^$</XML_NAMESPACE> + </AND> + </match> + <order>BY_NAME</order> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>.*</NAME> + <XML_ATTRIBUTE /> + <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE> + </AND> + </match> + <order>ANDROID_ATTRIBUTE_ORDER</order> + </rule> + </section> + <section> + <rule> + <match> + <AND> + <NAME>.*</NAME> + <XML_ATTRIBUTE /> + <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" /> + </codeStyleSettings> + </code_scheme> +</component> \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..79ee123c2b --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ +<component name="ProjectCodeStyleConfiguration"> + <state> + <option name="USE_PER_PROJECT_SETTINGS" value="true" /> + </state> +</component> \ No newline at end of file diff --git a/.idea/copyright/NewVector.xml b/.idea/copyright/NewVector.xml new file mode 100644 index 0000000000..72a4f2e779 --- /dev/null +++ b/.idea/copyright/NewVector.xml @@ -0,0 +1,6 @@ +<component name="CopyrightManager"> + <copyright> + <option name="notice" value="Copyright (c) &#36;today.year New Vector Ltd 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." /> + <option name="myName" value="NewVector" /> + </copyright> +</component> \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000000..0875fcecb1 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ +<component name="CopyrightManager"> + <settings default="NewVector" /> +</component> \ No newline at end of file diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml new file mode 100644 index 0000000000..aafe02a2c8 --- /dev/null +++ b/.idea/dictionaries/shared.xml @@ -0,0 +1,17 @@ +<component name="ProjectDictionaryState"> + <dictionary name="shared"> + <words> + <w>backstack</w> + <w>ftue</w> + <w>homeserver</w> + <w>kover</w> + <w>measurables</w> + <w>onboarding</w> + <w>placeables</w> + <w>showkase</w> + <w>snackbar</w> + <w>swipeable</w> + <w>textfields</w> + </words> + </dictionary> +</component> diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000000..6f7872211b Binary files /dev/null and b/.idea/icon.png differ diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000000..9a55c2de1f --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="KotlinJpsPluginSettings"> + <option name="version" value="1.8.22" /> + </component> +</project> \ No newline at end of file diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 0000000000..cd1f0658a7 --- /dev/null +++ b/.maestro/README.md @@ -0,0 +1,73 @@ +# Maestro + +Maestro is a framework that we are using to test navigation across the application. +To setup, please refer at [https://maestro.mobile.dev](https://maestro.mobile.dev) + +<!--- TOC --> + +* [Run test](#run-test) + * [Output](#output) +* [Write test](#write-test) +* [CI](#ci) +* [iOS](#ios) +* [Future](#future) + +<!--- END --> + +## Run test + +From root dir of the project + +*Note: Since ElementX does not allow account creation nor room creation, we have to use an existing account with an existing room to run maestro test suite. So to run locally, please replace `user` and `123` with your test matrix.org account credentials, and `my room` with one of a room this account has join. Note that the test will send messages to this room.* + +```shell +maestro test \ + -e APP_ID=io.element.android.x.debug \ + -e USERNAME=user1 \ + -e PASSWORD=123 \ + -e ROOM_NAME="MyRoom" \ + -e INVITEE1_MXID=user2 \ + -e INVITEE2_MXID=user3 \ + .maestro/allTests.yaml +``` + +### Output + +Test result will be printed on the console, and screenshots will be generated at `./build/maestro` + +## Write test + +Tests are yaml files. Generally each yaml file should leave the app in the same screen than at the beginning. + +Start the ElementX app and run this command to help writing test. + +```shell +maestro studio +``` + +Note that sometimes, this prevent running the test. So kill the `maestro studio` process to be able to run the test again. + +Also, if updating the application code, do not forget to deploy again the application before running the maestro tests. + +## CI + +The CI is running maestro using the workflow `.github/worflow/maestro.yaml` and [maestro cloud](https://cloud.mobile.dev/). For now we are limited to 100 runs a month. +Some GitHub secrets are used to be able to do that: `MAESTRO_CLOUD_API_KEY`, for now api key from `benoitm@element.io` maestro cloud account, and `MATRIX_MAESTRO_ACCOUNT_PASSWORD` which is the password of the account `@maestroelement:matrix.org`. This account contains a room `MyRoom` to be able to run the maestro test suite. + +## iOS + +Need to install `idb-companion` first + +```shell +brew install idb-companion +``` + +Also: +https://github.com/mobile-dev-inc/maestro/issues/146 +https://github.com/mobile-dev-inc/maestro/issues/107 +So you have to change your input keyboard to QWERTY for it to work properly. + +## Future + +- run on Element X iOS. This is already working but it need some change on the test to make it works. Could pass a PLATFORM parameter to have unique test and use conditional test. +- run specific test on both iOS and Android devices to make them communicate together. Could be possible to test room invite and join, verification, call, etc. To be done when Element X will be able to create account and create room. A main script would be able to detect the Android device and the iOS device, and run several maestro tests sequentially, using `--device` parameter to perform a global test. diff --git a/.maestro/allTests.yaml b/.maestro/allTests.yaml new file mode 100644 index 0000000000..8283e9fed5 --- /dev/null +++ b/.maestro/allTests.yaml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- runFlow: tests/init.yaml +- runFlow: tests/account/login.yaml +- runFlow: tests/settings/settings.yaml +- runFlow: tests/roomList/roomList.yaml +- runFlow: tests/account/logout.yaml diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml new file mode 100644 index 0000000000..df4b12f253 --- /dev/null +++ b/.maestro/tests/account/changeServer.yaml @@ -0,0 +1,17 @@ +appId: ${APP_ID} +--- +- tapOn: + id: "login-change_server" +- takeScreenshot: build/maestro/200-ChangeServer +- tapOn: "matrix.org" +- tapOn: + id: "login-change_server" +- tapOn: "Other" +- tapOn: + id: "change_server-server" +- inputText: "element" +- hideKeyboard +- tapOn: "element.io" +- tapOn: "Cancel" +- back +- back diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml new file mode 100644 index 0000000000..6126e34459 --- /dev/null +++ b/.maestro/tests/account/login.yaml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +--- +- tapOn: "Continue" +- runFlow: ../assertions/assertLoginDisplayed.yaml +- takeScreenshot: build/maestro/100-SignIn +- runFlow: changeServer.yaml +- runFlow: ../assertions/assertLoginDisplayed.yaml +- tapOn: + id: "login-continue" +- tapOn: + id: "login-email_username" +- inputText: ${USERNAME} +- pressKey: Enter +- tapOn: + id: "login-password" +- inputText: "wrong-password" +- pressKey: Enter +- tapOn: "Continue" +- tapOn: "OK" +- tapOn: + id: "login-password" +- eraseText: 20 +- inputText: ${PASSWORD} +- pressKey: Enter +- tapOn: "Continue" +- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml +- tapOn: "Continue" +- runFlow: ../assertions/assertAnalyticsDisplayed.yaml +- tapOn: "Not now" +- runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml new file mode 100644 index 0000000000..a06ac25e2d --- /dev/null +++ b/.maestro/tests/account/logout.yaml @@ -0,0 +1,13 @@ +appId: ${APP_ID} +--- +- tapOn: + id: "home_screen-settings" +- tapOn: "Sign out" +- takeScreenshot: build/maestro/900-SignOutDialg +# Ensure cancel cancels +- tapOn: "Cancel" +- tapOn: "Sign out" +- tapOn: + text: "Sign out" + index: 1 +- runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml new file mode 100644 index 0000000000..96a91a24af --- /dev/null +++ b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "Help improve Element X dbg" + timeout: 10_000 diff --git a/.maestro/tests/assertions/assertHomeDisplayed.yaml b/.maestro/tests/assertions/assertHomeDisplayed.yaml new file mode 100644 index 0000000000..6e9eec50db --- /dev/null +++ b/.maestro/tests/assertions/assertHomeDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "All Chats" + timeout: 10_000 diff --git a/.maestro/tests/assertions/assertInitDisplayed.yaml b/.maestro/tests/assertions/assertInitDisplayed.yaml new file mode 100644 index 0000000000..417ac87711 --- /dev/null +++ b/.maestro/tests/assertions/assertInitDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "Be in your element" + timeout: 10_000 diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml new file mode 100644 index 0000000000..3abd86ceef --- /dev/null +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: "Change account provider" + timeout: 10_000 diff --git a/.maestro/tests/assertions/assertRoomListSynced.yaml b/.maestro/tests/assertions/assertRoomListSynced.yaml new file mode 100644 index 0000000000..2d13c17df9 --- /dev/null +++ b/.maestro/tests/assertions/assertRoomListSynced.yaml @@ -0,0 +1,5 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: ${ROOM_NAME} + timeout: 10_000 diff --git a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml new file mode 100644 index 0000000000..73e8e78ef5 --- /dev/null +++ b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml @@ -0,0 +1,6 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: + id: "welcome_screen-title" + timeout: 10_000 diff --git a/.maestro/tests/init.yaml b/.maestro/tests/init.yaml new file mode 100644 index 0000000000..acd5f86dfd --- /dev/null +++ b/.maestro/tests/init.yaml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- clearState +- launchApp: + clearKeychain: true +- runFlow: ./assertions/assertInitDisplayed.yaml +- takeScreenshot: build/maestro/000-FirstScreen diff --git a/.maestro/tests/roomList/createAndDeleteDM.yaml b/.maestro/tests/roomList/createAndDeleteDM.yaml new file mode 100644 index 0000000000..f79a7418e4 --- /dev/null +++ b/.maestro/tests/roomList/createAndDeleteDM.yaml @@ -0,0 +1,13 @@ +appId: ${APP_ID} +--- +# Purpose: Test the creation and deletion of a DM room. +- tapOn: "Create a new conversation or room" +- tapOn: "Search for someone" +- inputText: ${INVITEE1_MXID} +- tapOn: + text: ${INVITEE1_MXID} + index: 1 +- takeScreenshot: build/maestro/330-createAndDeleteDM +- tapOn: "maestroelement2" +- tapOn: "Leave room" +- tapOn: "Leave" diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml new file mode 100644 index 0000000000..b2b7c1da0b --- /dev/null +++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml @@ -0,0 +1,33 @@ +appId: ${APP_ID} +--- +# Purpose: Test the creation and deletion of a room +- tapOn: "Create a new conversation or room" +- tapOn: "New room" +- tapOn: "Search for someone" +- inputText: ${INVITEE1_MXID} +- tapOn: + text: ${INVITEE1_MXID} + index: 1 +- tapOn: "Next" +- tapOn: "e.g. your project name" +- inputText: "aRoomName" +- tapOn: "What is this room about?" +- inputText: "aRoomTopic" +- tapOn: "Create" +- takeScreenshot: build/maestro/320-createAndDeleteRoom +- tapOn: "aRoomName" +- tapOn: "Invite people" +# assert there's 1 memeber and 1 invitee +- tapOn: "Search for someone" +- inputText: ${INVITEE2_MXID} +- tapOn: + text: ${INVITEE2_MXID} + index: 1 +- tapOn: "Send" +- tapOn: "Back" +- tapOn: "aRoomName" +- tapOn: "People" +# assert there's 1 memeber and 2 invitees +- tapOn: "Back" +- tapOn: "Leave room" +- tapOn: "Leave" diff --git a/.maestro/tests/roomList/roomContextMenu.yaml b/.maestro/tests/roomList/roomContextMenu.yaml new file mode 100644 index 0000000000..c2a8764558 --- /dev/null +++ b/.maestro/tests/roomList/roomContextMenu.yaml @@ -0,0 +1,14 @@ +appId: ${APP_ID} +--- +# Purpose: Test the context menu of a room in the room list +- longPressOn: ${ROOM_NAME} +- takeScreenshot: build/maestro/310-RoomList-ContextMenu +- tapOn: + text: "Settings" + index: 0 +- tapOn: "Back" +- longPressOn: ${ROOM_NAME} +- tapOn: + text: "Leave room" + index: 0 +- tapOn: "Cancel" diff --git a/.maestro/tests/roomList/roomList.yaml b/.maestro/tests/roomList/roomList.yaml new file mode 100644 index 0000000000..6365759e72 --- /dev/null +++ b/.maestro/tests/roomList/roomList.yaml @@ -0,0 +1,8 @@ +appId: ${APP_ID} +--- +- runFlow: searchRoomList.yaml +- takeScreenshot: build/maestro/300-RoomList +- runFlow: timeline/timeline.yaml +- runFlow: roomContextMenu.yaml +- runFlow: createAndDeleteRoom.yaml +- runFlow: createAndDeleteDM.yaml diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml new file mode 100644 index 0000000000..5125109197 --- /dev/null +++ b/.maestro/tests/roomList/searchRoomList.yaml @@ -0,0 +1,14 @@ +appId: ${APP_ID} +--- +- runFlow: ../assertions/assertRoomListSynced.yaml +- tapOn: "search" +- inputText: ${ROOM_NAME.substring(0, 3)} +- takeScreenshot: build/maestro/400-SearchRoom +- tapOn: ${ROOM_NAME} +# Back from timeline +- back +# Close keyboard +- hideKeyboard +# Back from search +- back +- runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/.maestro/tests/roomList/timeline/messages/location.yaml b/.maestro/tests/roomList/timeline/messages/location.yaml new file mode 100644 index 0000000000..73dca6eeb4 --- /dev/null +++ b/.maestro/tests/roomList/timeline/messages/location.yaml @@ -0,0 +1,7 @@ +appId: ${APP_ID} +--- +- takeScreenshot: build/maestro/520-Timeline +- tapOn: "Add attachment" +- tapOn: "Location" +- tapOn: "Share my location" +- takeScreenshot: build/maestro/521-Timeline diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml new file mode 100644 index 0000000000..4e3b7bbd45 --- /dev/null +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -0,0 +1,8 @@ +appId: ${APP_ID} +--- +- takeScreenshot: build/maestro/510-Timeline +- tapOn: "Message" +- inputText: "Hello world!" +- tapOn: "Send" +- hideKeyboard +- takeScreenshot: build/maestro/511-Timeline diff --git a/.maestro/tests/roomList/timeline/timeline.yaml b/.maestro/tests/roomList/timeline/timeline.yaml new file mode 100644 index 0000000000..bec566985d --- /dev/null +++ b/.maestro/tests/roomList/timeline/timeline.yaml @@ -0,0 +1,9 @@ +appId: ${APP_ID} +--- +# This is the name of one room +- tapOn: ${ROOM_NAME} +- takeScreenshot: build/maestro/500-Timeline +- runFlow: messages/text.yaml +- runFlow: messages/location.yaml +- back +- runFlow: ../../assertions/assertHomeDisplayed.yaml diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml new file mode 100644 index 0000000000..2fac9b108a --- /dev/null +++ b/.maestro/tests/settings/settings.yaml @@ -0,0 +1,30 @@ +appId: ${APP_ID} +--- +- tapOn: + id: "home_screen-settings" +- assertVisible: "Settings" +- takeScreenshot: build/maestro/600-Settings +- tapOn: + text: "Analytics" +- assertVisible: "Share analytics data" +- back + +- tapOn: + text: "Report bug" +- assertVisible: "Report a bug" +- back + +- tapOn: + text: "About" +- assertVisible: "Copyright" +- assertVisible: "Acceptable use policy" +- assertVisible: "Privacy policy" +- back + +- tapOn: + text: "Developer options" +- assertVisible: "Feature flags" +- back + +- back +- runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000000..89404cd73f --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,17 @@ +A full developer contributors list can be found [here](https://github.com/vector-im/element-x-android/graphs/contributors). + +# Core team: + +The element.io Android developer team. + +# Other contributors + +First of all, we thank all contributors who use Element and report problems on this GitHub project or via the integrated rageshake function. + +We do not forget all translators, for their work of translating Element into many languages. They are also the authors of Element. + +Feel free to add your name below, when you contribute to the project! + +Name | Matrix ID | GitHub +----------|-----------------------------|-------------------------------------- +name | @name:matrix.org | [githubID](https://github.com/githubID) diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000000..e04dbb63c8 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,4 @@ +Changes in Element X v0.1.0 (2023-07-19) +======================================== + +First release of Element X 🚀! diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..9db04197d5 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @vector-im/element-x-android-reviewers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..f50c8f2a89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,177 @@ +# Contributing to Element Android + +<!--- TOC --> + +* [Contributing code to Matrix](#contributing-code-to-matrix) +* [Developer onboarding](#developer-onboarding) +* [Android Studio settings](#android-studio-settings) +* [Compilation](#compilation) +* [Strings](#strings) + * [I want to add new strings to the project](#i-want-to-add-new-strings-to-the-project) + * [I want to help translating Element](#i-want-to-help-translating-element) +* [I want to submit a PR to fix an issue](#i-want-to-submit-a-pr-to-fix-an-issue) + * [Kotlin](#kotlin) + * [Changelog](#changelog) + * [Code quality](#code-quality) + * [ktlint](#ktlint) + * [knit](#knit) + * [lint](#lint) + * [Unit tests](#unit-tests) + * [Tests](#tests) + * [Accessibility](#accessibility) + * [Jetpack Compose](#jetpack-compose) + * [Authors](#authors) +* [Thanks](#thanks) + +<!--- END --> + +## Contributing code to Matrix + +Please read https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.md + +Element X Android support can be found in this room: [![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org). + +The rest of the document contains specific rules for Matrix Android projects + +## Developer onboarding + +For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md). + +## Android Studio settings + +Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`). +Please ensure that you're using the project formatting rules (which are in the project at .idea/codeStyles/), and format the file before committing them. + +## Compilation + +This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`. + +Note: please make sure that the configuration is `app` and not `samples.minimal`. + +## Strings + +The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS. + +### I want to add new strings to the project + +Only the core team can modify or add English strings to Localazy. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file. + +Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules) + +### I want to help translating Element + +Please note that the Localazy project is not open yet for external contributions. + +To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element). + +- If you want to fix an issue with an English string, please open an issue on the github project of ElementX (Android or iOS). Only the core team can modify or add English strings. +- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please go to [https://localazy.com/p/element](https://localazy.com/p/element). + +More informations can be found [in this README.md](./tools/localazy/README.md). + +## I want to submit a PR to fix an issue + +Please have a look in the [dedicated documentation](./docs/pull_request.md) about pull request. + +Please check if a corresponding issue exists. If yes, please let us know in a comment that you're working on it. +If an issue does not exist yet, it may be relevant to open a new issue and let us know that you're implementing it. + +### Kotlin + +This project is full Kotlin. Please do not write Java classes. + +### Changelog + +Please create at least one file under ./changelog.d containing details about your change. Towncrier will be used when preparing the release. + +Towncrier says to use the PR number for the filename, but the issue number is also fine. + +Supported filename extensions are: + +- ``.feature``: Signifying a new feature in Element Android or in the Matrix SDK. +- ``.bugfix``: Signifying a bug fix. +- ``.wip``: Signifying a work in progress change, typically a component of a larger feature which will be enabled once all tasks are complete. +- ``.doc``: Signifying a documentation improvement. +- ``.misc``: Any other changes. + +See https://github.com/twisted/towncrier#news-fragments if you need more details. + +### Code quality + +Make sure the following commands execute without any error: + +<pre> +./gradlew check +</pre> + +Some separate commands can also be run, see below. + +#### ktlint + +<pre> +./gradlew ktlintCheck --continue +</pre> + +Note that you can run + +<pre> +./gradlew ktlintFormat +</pre> + +For ktlint to fix some detected errors for you (you still have to check and commit the fix of course) + +#### knit + +[knit](https://github.com/Kotlin/kotlinx-knit) is a tool which checks markdown files on the project. Also it generates/updates the table of content (toc) of the markdown files. + +So everytime the toc should be updated, just run +<pre> +./gradlew knit +</pre> + +and commit the changes. + +The CI will check that markdown files are up to date by running + +<pre> +./gradlew knitCheck +</pre> + +#### lint + +<pre> +./gradlew lint +</pre> + +### Unit tests + +Make sure the following commands execute without any error: + +<pre> +./gradlew test +</pre> + +### Tests + +Element X is currently supported on Android Lollipop (API 21+): please test your change on an Android device (or Android emulator) running with API 21. Many issues can happen (including crashes) on older devices. +Also, if possible, please test your change on a real device. Testing on Android emulator may not be sufficient. + +You should consider adding Unit tests with your PR, and also integration tests (AndroidTest). Please refer to [this document](./docs/integration_tests.md) to install and run the integration test environment. + +### Accessibility + +Please consider accessibility as an important point. As a minimum requirement, in layout XML files please use attributes such as `android:contentDescription` and `android:importantForAccessibility`, and test with a screen reader if it's working well. You can add new string resources, dedicated to accessibility, in this case, please prefix theirs id with `a11y_`. + +For instance, when updating the image `src` of an ImageView, please also consider updating its `contentDescription`. A good example is a play pause button. + +### Jetpack Compose + +When adding or editing `@Composable`, make sure that you create a `@Preview` function, with suffix `Preview`. This will also create a UI test automatically. + +### Authors + +Feel free to add an entry in file AUTHORS.md + +## Thanks + +Thanks for contributing to Matrix projects! diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000000..a432dd0319 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'danger' diff --git a/README.md b/README.md index 97a612094d..e31acf87b8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,69 @@ -# element-x-android-poc -Prrof Of Concept to run a Matrix client on Android devices using the Matrix Rust Sdk and Jetpack compose +[![Latest build](https://github.com/vector-im/element-x-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/vector-im/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) +[![codecov](https://codecov.io/github/vector-im/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/vector-im/element-x-android) +[![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org) +[![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget) + +# element-x-android + +ElementX Android is a [Matrix](https://matrix.org/) Android Client provided by [Element](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionality. + +The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using Jetpack compose. + +<!--- TOC --> + +* [Screenshots](#screenshots) +* [Rust SDK](#rust-sdk) +* [Status](#status) +* [Contributing](#contributing) +* [Build instructions](#build-instructions) +* [Support](#support) +* [Copyright & License](#copyright-&-license) + +<!--- END --> + +## Screenshots + +Here are some early screenshots of the application: + +|<img src=./docs/images/screen1.png width=280 />|<img src=./docs/images/screen2.png width=280 />|<img src=./docs/images/screen3.png width=280 />|<img src=./docs/images/screen4.png width=280 />| +|-|-|-|-| + +## Rust SDK + +ElementX leverages the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) through an FFI layer that the final client can directly import and use. + +We're doing this as a way to share code between platforms and while we've seen promising results it's still in the experimental stage and bound to change. + +## Status + +This project is in work in progress. The app does not cover yet all functionalities we expect. + +## Contributing + +Please see our [contribution guide](CONTRIBUTING.md). + +Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#element-android:matrix.org). + +## Build instructions + +Just clone the project and open it in Android Studio. +Makes sure to select the `app` configuration when building (as we also have sample apps in the project). + +## Support + +When you are experiencing an issue on ElementX Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues) +and then in [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). +If after your research you still have a question, ask at [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting (Rageshake) from the Element application by shaking your phone or going to the application settings. This is especially recommended when you encounter a crash. + +## Copyright & License + +Copyright (c) 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in compliance with the License. You may obtain a copy of the License in the [LICENSE](LICENSE) file, or at: + +[http://www.apache.org/licenses/LICENSE-2.0](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. diff --git a/anvilannotations/.gitignore b/anvilannotations/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/anvilannotations/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/anvilannotations/build.gradle.kts b/anvilannotations/build.gradle.kts new file mode 100644 index 0000000000..aac12fbc58 --- /dev/null +++ b/anvilannotations/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ +plugins { + alias(libs.plugins.kotlin.jvm) + id("com.android.lint") +} + +dependencies { + api(libs.inject) +} diff --git a/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt b/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt new file mode 100644 index 0000000000..cf9f2f3684 --- /dev/null +++ b/anvilannotations/src/main/kotlin/io/element/android/anvilannotations/ContributesNode.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.anvilannotations + +import kotlin.reflect.KClass + +/** + * Adds Node to the specified component graph. + * Equivalent to the following declaration: + * + * @Module + * @ContributesTo(Scope::class) + * abstract class YourNodeModule { + + * @Binds + * @IntoMap + * @NodeKey(YourNode::class) + * abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*> + *} + + */ +@Target(AnnotationTarget.CLASS) +annotation class ContributesNode( + val scope: KClass<*>, +) diff --git a/anvilcodegen/.gitignore b/anvilcodegen/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/anvilcodegen/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts new file mode 100644 index 0000000000..57758f8909 --- /dev/null +++ b/anvilcodegen/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kapt) +} + +dependencies { + implementation(projects.anvilannotations) + api(libs.anvil.compiler.api) + implementation(libs.anvil.compiler.utils) + implementation("com.squareup:kotlinpoet:1.14.2") + implementation(libs.dagger) + compileOnly(libs.google.autoservice.annotations) + kapt(libs.google.autoservice) +} diff --git a/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt new file mode 100644 index 0000000000..576a52df89 --- /dev/null +++ b/anvilcodegen/src/main/kotlin/io/element/android/anvilcodegen/ContributesNodeCodeGenerator.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalAnvilApi::class) + +package io.element.android.anvilcodegen + +import com.google.auto.service.AutoService +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.ExperimentalAnvilApi +import com.squareup.anvil.compiler.api.AnvilCompilationException +import com.squareup.anvil.compiler.api.AnvilContext +import com.squareup.anvil.compiler.api.CodeGenerator +import com.squareup.anvil.compiler.api.GeneratedFile +import com.squareup.anvil.compiler.api.createGeneratedFile +import com.squareup.anvil.compiler.internal.asClassName +import com.squareup.anvil.compiler.internal.buildFile +import com.squareup.anvil.compiler.internal.fqName +import com.squareup.anvil.compiler.internal.reference.ClassReference +import com.squareup.anvil.compiler.internal.reference.asClassName +import com.squareup.anvil.compiler.internal.reference.classAndInnerClassReferences +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeSpec +import dagger.Binds +import dagger.Module +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.multibindings.IntoMap +import io.element.android.anvilannotations.ContributesNode +import org.jetbrains.kotlin.descriptors.ModuleDescriptor +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile +import java.io.File + +/** + * This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically + * handle the rest of the Dagger wiring required for constructor injection. + */ +@AutoService(CodeGenerator::class) +class ContributesNodeCodeGenerator : CodeGenerator { + + override fun isApplicable(context: AnvilContext): Boolean = true + + override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection<KtFile>): Collection<GeneratedFile> { + return projectFiles.classAndInnerClassReferences(module) + .filter { it.isAnnotatedWith(ContributesNode::class.fqName) } + .flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) } + .toList() + } + + private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val moduleClassName = "${nodeClass.shortName}_Module" + val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope() + val content = FileSpec.buildFile(generatedPackage, moduleClassName) { + addType( + TypeSpec.classBuilder(moduleClassName) + .addModifiers(KModifier.ABSTRACT) + .addAnnotation(Module::class) + .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build()) + .addFunction( + FunSpec.builder("bind${nodeClass.shortName}Factory") + .addModifiers(KModifier.ABSTRACT) + .addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory")) + .returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR)) + .addAnnotation(Binds::class) + .addAnnotation(IntoMap::class) + .addAnnotation( + AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember( + "%T::class", + nodeClass.asClassName() + ).build() + ) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content) + } + + private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory" + val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) } + val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty() + if (constructor == null || assistedParameters.size != 2) { + throw AnvilCompilationException( + "${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters", + element = nodeClass.clazz, + ) + } + val contextAssistedParam = assistedParameters[0] + if (contextAssistedParam.name != "buildContext") { + throw AnvilCompilationException( + "${nodeClass.fqName} @Assisted parameter must be named buildContext", + element = contextAssistedParam.parameter, + ) + } + val pluginsAssistedParam = assistedParameters[1] + if (pluginsAssistedParam.name != "plugins") { + throw AnvilCompilationException( + "${nodeClass.fqName} @Assisted parameter must be named plugins", + element = pluginsAssistedParam.parameter, + ) + } + + val nodeClassName = nodeClass.asClassName() + val buildContextClassName = contextAssistedParam.type().asTypeName() + val pluginsClassName = pluginsAssistedParam.type().asTypeName() + val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) { + addType( + TypeSpec.interfaceBuilder(assistedFactoryClassName) + .addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName)) + .addAnnotation(AssistedFactory::class) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT) + .addParameter("buildContext", buildContextClassName) + .addParameter("plugins", pluginsClassName) + .returns(nodeClassName) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, assistedFactoryClassName, content) + } + + companion object { + private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory") + private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey") + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000000..6a28adecf1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +@file:Suppress("UnstableApiUsage") + +import com.android.build.api.variant.FilterConfiguration.FilterType.ABI +import extension.allFeaturesImpl +import extension.allLibrariesImpl +import extension.allServicesImpl + +plugins { + id("io.element.android-compose-application") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + alias(libs.plugins.kapt) + id("com.google.firebase.appdistribution") version "4.0.0" + id("org.jetbrains.kotlinx.knit") version "0.4.0" + id("kotlin-parcelize") + // To be able to update the firebase.xml files, uncomment and build the project + // id("com.google.gms.google-services") +} + +android { + namespace = "io.element.android.x" + + testOptions { unitTests.isIncludeAndroidResources = true } + + defaultConfig { + applicationId = "io.element.android.x" + targetSdk = Versions.targetSdk + versionCode = Versions.versionCode + versionName = Versions.versionName + + vectorDrawables { + useSupportLibrary = true + } + + // Keep abiFilter for the universalApk + ndk { + abiFilters += listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64") + } + + // Ref: https://developer.android.com/studio/build/configure-apk-splits.html#configure-abi-split + splits { + // Configures multiple APKs based on ABI. + abi { + // Enables building multiple APKs per ABI. + isEnable = true + // By default all ABIs are included, so use reset() and include to specify that we only + // want APKs for armeabi-v7a, x86, arm64-v8a and x86_64. + // Resets the list of ABIs that Gradle should create APKs for to none. + reset() + // Specifies a list of ABIs that Gradle should create APKs for. + include("armeabi-v7a", "x86", "arm64-v8a", "x86_64") + // Generate a universal APK that includes all ABIs, so user who installs from CI tool can use this one by default. + isUniversalApk = true + } + } + } + + signingConfigs { + named("debug") { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("./signature/debug.keystore") + storePassword = "android" + } + register("nightly") { + keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID") + ?: project.property("signing.element.nightly.keyId") as? String? + keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD") + ?: project.property("signing.element.nightly.keyPassword") as? String? + storeFile = file("./signature/nightly.keystore") + storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD") + ?: project.property("signing.element.nightly.storePassword") as? String? + } + } + + buildTypes { + named("debug") { + resValue("string", "app_name", "Element X dbg") + applicationIdSuffix = ".debug" + signingConfig = signingConfigs.getByName("debug") + } + + named("release") { + resValue("string", "app_name", "Element X") + signingConfig = signingConfigs.getByName("debug") + + postprocessing { + isRemoveUnusedCode = true + isObfuscate = false + isOptimizeCode = true + isRemoveUnusedResources = true + proguardFiles("proguard-rules.pro") + } + } + + register("nightly") { + val release = getByName("release") + initWith(release) + applicationIdSuffix = ".nightly" + versionNameSuffix = "-nightly" + resValue("string", "app_name", "Element X nightly") + matchingFallbacks += listOf("release") + signingConfig = signingConfigs.getByName("nightly") + + postprocessing { + initWith(release.postprocessing) + } + + firebaseAppDistribution { + artifactType = "APK" + // We upload the universal APK to fix this error: + // "App Distribution found more than 1 output file for this variant. + // Please contact firebase-support@google.com for help using APK splits with App Distribution." + artifactPath = "$rootDir/app/build/outputs/apk/nightly/app-universal-nightly.apk" + // This file will be generated by the GitHub action + releaseNotesFile = "CHANGES_NIGHTLY.md" + groups = "external-testers" + // This should not be required, but if I do not add the appId, I get this error: + // "App Distribution halted because it had a problem uploading the APK: [404] Requested entity was not found." + appId = "1:912726360885:android:e17435e0beb0303000427c" + } + } + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + buildConfig = true + } +} + +androidComponents { + // map for the version codes last digit + // x86 must have greater values than arm + // 64 bits have greater value than 32 bits + val abiVersionCodes = mapOf( + "armeabi-v7a" to 1, + "arm64-v8a" to 2, + "x86" to 3, + "x86_64" to 4, + ) + + onVariants { variant -> + // Assigns a different version code for each output APK + // other than the universal APK. + variant.outputs.forEach { output -> + val name = output.filters.find { it.filterType == ABI }?.identifier + + // Stores the value of abiCodes that is associated with the ABI for this variant. + val abiCode = abiVersionCodes[name] ?: 0 + // Assigns the new version code to output.versionCode, which changes the version code + // for only the output APK, not for the variant itself. + output.versionCode.set((output.versionCode.get() ?: 0) * 10 + abiCode) + } + } +} + +// Knit +apply { + plugin("kotlinx-knit") +} + +knit { + files = fileTree(project.rootDir) { + include( + "**/*.md", + "**/*.kt", + "*/*.kts", + ) + exclude( + "**/build/**", + "*/.gradle/**", + "*/towncrier/template.md", + "**/CHANGES.md", + ) + } +} + +dependencies { + allLibrariesImpl() + allServicesImpl() + allFeaturesImpl(rootDir, logger) + implementation(projects.anvilannotations) + implementation(projects.appnav) + anvil(projects.anvilcodegen) + + coreLibraryDesugaring(libs.android.desugar) + implementation(libs.appyx.core) + implementation(libs.androidx.splash) + implementation(libs.androidx.core) + implementation(libs.androidx.corektx) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.startup) + implementation(libs.androidx.preference) + implementation(libs.coil) + + implementation(platform(libs.network.okhttp.bom)) + implementation(libs.network.okhttp.logging) + implementation(libs.serialization.json) + + implementation(libs.vanniktech.emoji) + + implementation(libs.dagger) + kapt(libs.dagger.compiler) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + ksp(libs.showkase.processor) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000000..949ccfce40 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,34 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# JNA +-dontwarn java.awt.* +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } + +# kotlinx.serialization + +# Kotlin serialization looks up the generated serializer classes through a function on companion +# objects. The companions are looked up reflectively so we need to explicitly keep these functions. +-keepclasseswithmembers class **.*$Companion { + kotlinx.serialization.KSerializer serializer(...); +} +# If a companion has the serializer function, keep the companion field on the original type so that +# the reflective lookup succeeds. +-if class **.*$Companion { + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class <1>.<2> { + <1>.<2>$Companion Companion; +} + +# OkHttp platform used only on JVM and when Conscrypt and other security providers are available. +# Taken from https://raw.githubusercontent.com/square/okhttp/master/okhttp/src/jvmMain/resources/META-INF/proguard/okhttp3.pro +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/app/signature/debug.keystore b/app/signature/debug.keystore new file mode 100644 index 0000000000..4a15fc9eca Binary files /dev/null and b/app/signature/debug.keystore differ diff --git a/app/signature/nightly.keystore b/app/signature/nightly.keystore new file mode 100644 index 0000000000..a0e9ba413b Binary files /dev/null and b/app/signature/nightly.keystore differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2917c5199b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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" + xmlns:tools="http://schemas.android.com/tools"> + + <!-- To be able to install APK from the application --> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + + <application + android:name=".ElementXApplication" + android:allowBackup="true" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/backup_rules" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.ElementX" + tools:targetApi="33"> + + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + android:exported="false" + tools:node="merge"> + + <meta-data + android:name='androidx.lifecycle.ProcessLifecycleInitializer' + android:value='androidx.startup' /> + + </provider> + + <activity + android:name=".MainActivity" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" + android:exported="true" + android:launchMode="singleTop" + android:theme="@style/Theme.ElementX.Splash" + android:windowSoftInputMode="adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + <!-- Handle deep-link for notification ./tools/adb/deeplink.sh --> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.DEFAULT" /> + <data + android:host="open" + android:scheme="elementx" /> + </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="io.element" /> + </intent-filter> + </activity> + + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + android:exported="false" + tools:node="remove" /> + + <provider + android:authorities="${applicationId}.fileprovider" + android:name="androidx.core.content.FileProvider" + android:grantUriPermissions="true" + android:exported="false"> + <meta-data android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/file_providers" /> + </provider> + + </application> + +</manifest> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..a5bcf735dc Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt new file mode 100644 index 0000000000..ec3259fb7c --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.x + +import android.app.Application +import androidx.startup.AppInitializer +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.x.di.AppComponent +import io.element.android.x.di.DaggerAppComponent +import io.element.android.x.info.logApplicationInfo +import io.element.android.x.initializer.CrashInitializer +import io.element.android.x.initializer.EmojiInitializer +import io.element.android.x.initializer.MatrixInitializer +import io.element.android.x.initializer.TimberInitializer + +class ElementXApplication : Application(), DaggerComponentOwner { + + private lateinit var appComponent: AppComponent + + override val daggerComponent: Any + get() = appComponent + + override fun onCreate() { + super.onCreate() + appComponent = DaggerAppComponent.factory().create(applicationContext) + AppInitializer.getInstance(this).apply { + initializeComponent(CrashInitializer::class.java) + initializeComponent(TimberInitializer::class.java) + initializeComponent(MatrixInitializer::class.java) + initializeComponent(EmojiInitializer::class.java) + } + logApplicationInfo() + } +} diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt new file mode 100644 index 0000000000..cf37b22159 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.x + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import com.bumble.appyx.core.integration.NodeHost +import com.bumble.appyx.core.integrationpoint.NodeComponentActivity +import com.bumble.appyx.core.plugin.NodeReadyObserver +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher +import io.element.android.x.di.AppBindings +import io.element.android.x.intent.SafeUriHandler +import timber.log.Timber + +private val loggerTag = LoggerTag("MainActivity") + +class MainActivity : NodeComponentActivity() { + + private lateinit var mainNode: MainNode + + private lateinit var appBindings: AppBindings + + override fun onCreate(savedInstanceState: Bundle?) { + Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}") + installSplashScreen() + super.onCreate(savedInstanceState) + appBindings = bindings() + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + MainContent(appBindings) + } + } + + @Composable + private fun MainContent(appBindings: AppBindings) { + ElementTheme { + CompositionLocalProvider( + LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), + LocalUriHandler provides SafeUriHandler(this), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + NodeHost(integrationPoint = appyxIntegrationPoint) { + MainNode( + it, + appBindings.mainDaggerComponentOwner(), + plugins = listOf( + object : NodeReadyObserver<MainNode> { + override fun init(node: MainNode) { + Timber.tag(loggerTag.value).w("onMainNodeInit") + mainNode = node + mainNode.handleIntent(intent) + } + } + ) + ) + } + } + } + } + } + + /** + * Called when: + * - the launcher icon is clicked (if the app is already running); + * - a notification is clicked. + * - the app is going to background (<- this is strange) + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Timber.tag(loggerTag.value).w("onNewIntent") + // If the mainNode is not init yet, keep the intent for later. + // It can happen when the activity is killed by the system. The methods are called in this order : + // onCreate(savedInstanceState=true) -> onNewIntent -> onResume -> onMainNodeInit + if (::mainNode.isInitialized) { + mainNode.handleIntent(intent) + } else { + setIntent(intent) + } + } + + override fun onPause() { + super.onPause() + Timber.tag(loggerTag.value).w("onPause") + } + + override fun onResume() { + super.onResume() + Timber.tag(loggerTag.value).w("onResume") + } + + override fun onDestroy() { + super.onDestroy() + Timber.tag(loggerTag.value).w("onDestroy") + } +} diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt new file mode 100644 index 0000000000..8e7d0f194d --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.appnav.LoggedInFlowNode +import io.element.android.appnav.room.RoomLoadedFlowNode +import io.element.android.appnav.RootFlowNode +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.x.di.MainDaggerComponentsOwner +import io.element.android.x.di.RoomComponent +import io.element.android.x.di.SessionComponent +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +class MainNode( + buildContext: BuildContext, + private val mainDaggerComponentOwner: MainDaggerComponentsOwner, + plugins: List<Plugin>, +) : + ParentNode<MainNode.RootNavTarget>( + navModel = PermanentNavModel( + navTargets = setOf(RootNavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, + ), + DaggerComponentOwner by mainDaggerComponentOwner { + + private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback { + override fun onFlowCreated(identifier: String, client: MatrixClient) { + val component = bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(client).build() + mainDaggerComponentOwner.addComponent(identifier, component) + } + + override fun onFlowReleased(identifier: String, client: MatrixClient) { + mainDaggerComponentOwner.removeComponent(identifier) + } + } + + private val roomFlowNodeCallback = object : RoomLoadedFlowNode.LifecycleCallback { + override fun onFlowCreated(identifier: String, room: MatrixRoom) { + val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build() + mainDaggerComponentOwner.addComponent(identifier, component) + } + + override fun onFlowReleased(identifier: String, room: MatrixRoom) { + mainDaggerComponentOwner.removeComponent(identifier) + } + } + + override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node { + return createNode<RootFlowNode>( + context = buildContext, + plugins = listOf( + loggedInFlowNodeCallback, + roomFlowNodeCallback, + ) + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = navModel) + } + + fun handleIntent(intent: Intent) { + lifecycleScope.launch { + waitForChildAttached<RootFlowNode>().handleIntent(intent) + } + } + + @Parcelize + object RootNavTarget : Parcelable +} diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt new file mode 100644 index 0000000000..4d75d8601e --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface AppBindings { + fun mainDaggerComponentOwner(): MainDaggerComponentsOwner + fun snackbarDispatcher(): SnackbarDispatcher +} diff --git a/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt b/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt new file mode 100644 index 0000000000..d614556413 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppComponent.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import android.content.Context +import com.squareup.anvil.annotations.MergeComponent +import dagger.BindsInstance +import dagger.Component +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn + +@SingleIn(AppScope::class) +@MergeComponent(AppScope::class) +interface AppComponent : NodeFactoriesBindings { + + @Component.Factory + interface Factory { + fun create(@ApplicationContext @BindsInstance context: Context): AppComponent + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt new file mode 100644 index 0000000000..a1d0b50522 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import androidx.preference.PreferenceManager +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.di.SingleIn +import io.element.android.x.BuildConfig +import io.element.android.x.R +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.plus +import java.io.File + +@Module +@ContributesTo(AppScope::class) +object AppModule { + + @Provides + fun providesBaseDirectory(@ApplicationContext context: Context): File { + return File(context.filesDir, "sessions") + } + + @Provides + fun providesResources(@ApplicationContext context: Context): Resources { + return context.resources + } + + @Provides + @SingleIn(AppScope::class) + fun providesAppCoroutineScope(): CoroutineScope { + return MainScope() + CoroutineName("ElementX Scope") + } + + @Provides + @SingleIn(AppScope::class) + fun providesBuildType(): BuildType { + return BuildType.valueOf(BuildConfig.BUILD_TYPE.uppercase()) + } + + @Provides + @SingleIn(AppScope::class) + fun providesBuildMeta(@ApplicationContext context: Context, buildType: BuildType) = BuildMeta( + isDebuggable = BuildConfig.DEBUG, + buildType = buildType, + applicationName = context.getString(R.string.app_name), + applicationId = BuildConfig.APPLICATION_ID, + lowPrivacyLoggingEnabled = false, // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, + versionName = BuildConfig.VERSION_NAME, + versionCode = BuildConfig.VERSION_CODE, + gitRevision = "TODO", // BuildConfig.GIT_REVISION, + gitRevisionDate = "TODO", // BuildConfig.GIT_REVISION_DATE, + gitBranchName = "TODO", // BuildConfig.GIT_BRANCH_NAME, + flavorDescription = "TODO", // BuildConfig.FLAVOR_DESCRIPTION, + flavorShortDescription = "TODO", // BuildConfig.SHORT_FLAVOR_DESCRIPTION, + ) + + @Provides + @SingleIn(AppScope::class) + @DefaultPreferences + fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + @Provides + @SingleIn(AppScope::class) + fun providesCoroutineDispatchers(): CoroutineDispatchers { + return CoroutineDispatchers( + io = Dispatchers.IO, + computation = Dispatchers.Default, + main = Dispatchers.Main, + ) + } + + @Provides + @SingleIn(AppScope::class) + fun provideSnackbarDispatcher(): SnackbarDispatcher { + return SnackbarDispatcher() + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt new file mode 100644 index 0000000000..de800bb587 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/MainDaggerComponentsOwner.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import android.content.Context +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@SingleIn(AppScope::class) +class MainDaggerComponentsOwner @Inject constructor(@ApplicationContext context: Context) : DaggerComponentOwner { + + private val daggerComponents = LinkedHashMap<String, Any>().apply { + put("app", (context as DaggerComponentOwner).daggerComponent) + } + + fun addComponent(identifier: String, component: Any) { + daggerComponents[identifier] = component + } + + fun removeComponent(identifier: String) { + daggerComponents.remove(identifier) + } + + /** + * We expose the dagger components in the opposite order they arrived. + * So we pick the most recent component when searching with the [io.element.android.libraries.architecture.bindings] methods. + */ + override val daggerComponent: Any + get() = daggerComponents.values.reversed() +} diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt new file mode 100644 index 0000000000..68c700bdb8 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/RoomComponent.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.BindsInstance +import dagger.Subcomponent +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.room.MatrixRoom + +@SingleIn(RoomScope::class) +@MergeSubcomponent(RoomScope::class) +interface RoomComponent : NodeFactoriesBindings { + + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun room(room: MatrixRoom): Builder + fun build(): RoomComponent + } + + @ContributesTo(SessionScope::class) + interface ParentBindings { + fun roomComponentBuilder(): Builder + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt b/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt new file mode 100644 index 0000000000..54e8c27498 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/SessionComponent.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.di + +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.BindsInstance +import dagger.Subcomponent +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient + +@SingleIn(SessionScope::class) +@MergeSubcomponent(SessionScope::class) +interface SessionComponent : NodeFactoriesBindings { + + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun client(matrixClient: MatrixClient): Builder + fun build(): SessionComponent + } + + @ContributesTo(AppScope::class) + interface ParentBindings { + fun sessionComponentBuilder(): Builder + } +} diff --git a/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt new file mode 100644 index 0000000000..49c2cc5782 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.icon + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.x.R + +@Preview +@Composable +fun IconPreview( + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null) + Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null) + } +} + +@Preview +@Composable +fun RoundIconPreview( + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.clip(shape = CircleShape)) { + Image(painter = painterResource(id = R.mipmap.ic_launcher_background), contentDescription = null) + Image(painter = painterResource(id = R.mipmap.ic_launcher_foreground), contentDescription = null) + } +} diff --git a/app/src/main/kotlin/io/element/android/x/info/Logs.kt b/app/src/main/kotlin/io/element/android/x/info/Logs.kt new file mode 100644 index 0000000000..9e96f48e2d --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/info/Logs.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.info + +import io.element.android.x.BuildConfig +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun logApplicationInfo() { + val appVersion = buildString { + append(BuildConfig.VERSION_NAME) + append(" (") + append(BuildConfig.VERSION_CODE) + append(") - ") + append(BuildConfig.BUILD_TYPE) + } + // TODO Get SDK version somehow + val sdkVersion = "SDK VERSION (TODO)" + val date = SimpleDateFormat("MM-dd HH:mm:ss.SSSZ", Locale.US).format(Date()) + + Timber.d("----------------------------------------------------------------") + Timber.d("----------------------------------------------------------------") + Timber.d(" Application version: $appVersion") + Timber.d(" SDK version: $sdkVersion") + Timber.d(" Local time: $date") + Timber.d("----------------------------------------------------------------") + Timber.d("----------------------------------------------------------------\n\n\n\n") +} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt new file mode 100644 index 0000000000..c947bc20e3 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.features.rageshake.impl.crash.VectorUncaughtExceptionHandler + +class CrashInitializer : Initializer<Unit> { + + override fun create(context: Context) { + VectorUncaughtExceptionHandler(context).activate() + } + + override fun dependencies(): List<Class<out Initializer<*>>> = emptyList() +} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt new file mode 100644 index 0000000000..dd1e7455c6 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/EmojiInitializer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.initializer + +import androidx.startup.Initializer +import com.vanniktech.emoji.EmojiManager +import com.vanniktech.emoji.google.GoogleEmojiProvider + +class EmojiInitializer : Initializer<Unit> { + override fun create(context: android.content.Context) { + EmojiManager.install(GoogleEmojiProvider()) + } + + override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf() +} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt new file mode 100644 index 0000000000..5eebc88756 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.libraries.matrix.impl.tracing.setupTracing +import io.element.android.libraries.matrix.api.tracing.TracingConfigurations +import io.element.android.x.BuildConfig + +class MatrixInitializer : Initializer<Unit> { + + override fun create(context: Context) { + if (BuildConfig.DEBUG) { + setupTracing(TracingConfigurations.debug) + } else { + setupTracing(TracingConfigurations.release) + } + } + + override fun dependencies(): List<Class<out Initializer<*>>> = listOf(TimberInitializer::class.java) +} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt new file mode 100644 index 0000000000..5a641d75c6 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.features.rageshake.impl.logs.VectorFileLogger +import io.element.android.x.BuildConfig +import timber.log.Timber + +class TimberInitializer : Initializer<Unit> { + + override fun create(context: Context) { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + Timber.plant(VectorFileLogger(context)) + } + + override fun dependencies(): List<Class<out Initializer<*>>> = emptyList() +} diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt new file mode 100644 index 0000000000..88a86b9467 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.intent + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.deeplink.DeepLinkCreator +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.intent.IntentProvider +import io.element.android.x.MainActivity +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class IntentProviderImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val deepLinkCreator: DeepLinkCreator, +) : IntentProvider { + override fun getViewRoomIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + ): Intent { + return Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = deepLinkCreator.room(sessionId, roomId, threadId).toUri() + } + } + + override fun getInviteListIntent(sessionId: SessionId): Intent { + return Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = deepLinkCreator.inviteList(sessionId).toUri() + } + } +} diff --git a/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt b/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt new file mode 100644 index 0000000000..582cd3b7d5 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.x.intent + +import android.app.Activity +import androidx.compose.ui.platform.UriHandler +import io.element.android.libraries.androidutils.system.openUrlInExternalApp + +class SafeUriHandler(private val activity: Activity) : UriHandler { + override fun openUri(uri: String) { + activity.openUrlInExternalApp(uri) + } +} diff --git a/app/src/main/res/drawable/transparent.xml b/app/src/main/res/drawable/transparent.xml new file mode 100644 index 0000000000..b7e6de414f --- /dev/null +++ b/app/src/main/res/drawable/transparent.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + + <solid android:color="#00000000" /> + +</shape> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..82724deb96 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@mipmap/ic_launcher_background"/> + <foreground android:drawable="@mipmap/ic_launcher_foreground"/> + <!-- Waiting for design monochrome android:drawable="@mipmap/ic_launcher_monochrome" /--> +</adaptive-icon> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..82724deb96 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@mipmap/ic_launcher_background"/> + <foreground android:drawable="@mipmap/ic_launcher_foreground"/> + <!-- Waiting for design monochrome android:drawable="@mipmap/ic_launcher_monochrome" /--> +</adaptive-icon> diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..4bbfe30ed2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000000..3510298288 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..20b221702c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000..9dd38fd073 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..001f74e9ba Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000000..d3ce4fb227 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..2d4bab337c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000..42a631def3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..0adb2f3b52 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..e73012b493 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..33fd9ab681 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..05f718cf3b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..6dd6aa3ee6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..1a6c540c52 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..7c4cf9729b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..448fa261cc Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..adb2a0c794 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000..576bdfc52d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..89f421aafc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000..d5c5e2af7d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..a6572451d7 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <style name="Theme.ElementX.Splash" parent="Theme.SplashScreen"> + <item name="windowSplashScreenBackground">@color/splashscreen_bg_dark</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item> + <item name="postSplashScreenTheme">@style/Theme.ElementX</item> + </style> + + <style name="Theme.ElementX" parent="Theme.Material3.Dark" /> +</resources> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..1716e39012 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + <!-- Must be equal to DarkDesignTokens.colorThemeBg --> + <color name="splashscreen_bg_dark">#FF101317</color> + <!-- Must be equal to LightDesignTokens.colorThemeBg --> + <color name="splashscreen_bg_light">#FFFFFFFF</color> +</resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..fee1385c85 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> +<resources xmlns:tools="http://schemas.android.com/tools"> + <!-- The https://github.com/LikeTheSalad/android-stem requires a non empty strings.xml --> + <string name="ignored_placeholder" translatable="false" tools:ignore="UnusedResources">ignored</string> +</resources> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..530821a92b --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + <style name="Theme.ElementX.Splash" parent="Theme.SplashScreen"> + <item name="windowSplashScreenBackground">@color/splashscreen_bg_light</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/transparent</item> + <item name="postSplashScreenTheme">@style/Theme.ElementX</item> + </style> + <style name="Theme.ElementX" parent="Theme.Material3.Light" /> +</resources> diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..37fe0011dc --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<!-- + Sample backup rules file; uncomment and customize as necessary. + See https://developer.android.com/guide/topics/data/autobackup + for details. + Note: This file is ignored for devices older that API 31 + See https://developer.android.com/about/versions/12/backup-restore +--> +<full-backup-content> + <!-- + <include domain="sharedpref" path="."/> + <exclude domain="sharedpref" path="device.xml"/> +--> +</full-backup-content> diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..a6ecda4638 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<!-- + Sample data extraction rules file; uncomment and customize as necessary. + See https://developer.android.com/about/versions/12/backup-restore#xml-changes + for details. +--> +<data-extraction-rules> + <cloud-backup> + <!-- TODO: Use <include> and <exclude> to control what is backed up. + <include .../> + <exclude .../> + --> + </cloud-backup> + <!-- + <device-transfer> + <include .../> + <exclude .../> + </device-transfer> + --> +</data-extraction-rules> diff --git a/app/src/main/res/xml/file_providers.xml b/app/src/main/res/xml/file_providers.xml new file mode 100644 index 0000000000..8afae6f313 --- /dev/null +++ b/app/src/main/res/xml/file_providers.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<paths xmlns:android="http://schemas.android.com/apk/res/android"> + <cache-path name="cache" path="." /> +</paths> diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts new file mode 100644 index 0000000000..6abc3c656b --- /dev/null +++ b/appnav/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +@file:Suppress("UnstableApiUsage") + +import extension.allFeaturesApi + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + alias(libs.plugins.kapt) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.appnav" +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(libs.dagger) + kapt(libs.dagger.compiler) + + allFeaturesApi(rootDir, logger) + + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.deeplink) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) + + implementation(libs.coil) + + implementation(projects.features.ftue.api) + + implementation(projects.services.apperror.impl) + implementation(projects.services.appnavstate.api) + implementation(projects.services.analytics.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.rageshake.impl) + testImplementation(projects.services.appnavstate.test) + testImplementation(libs.test.appyx.junit) + testImplementation(libs.test.arch.core) + + ksp(libs.showkase.processor) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt new file mode 100644 index 0000000000..36b267debb --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.NewRoot +import com.bumble.appyx.navmodel.backstack.operation.Remove + +/** + * Don't process NewRoot if the nav target already exists in the stack. + */ +fun <T : Any> BackStack<T>.safeRoot(element: T) { + val containsRoot = elements.value.any { + it.key.navTarget == element + } + if (containsRoot) return + accept(NewRoot(element)) +} + +/** + * Remove the last element on the backstack equals to the given one. + */ +fun <T : Any> BackStack<T>.removeLast(element: T) { + val lastExpectedNavElement = elements.value.lastOrNull { + it.key.navTarget == element + } ?: return + accept(Remove(lastExpectedNavElement.key)) +} + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt new file mode 100644 index 0000000000..64c9ec7c4f --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoggedInEventProcessor @Inject constructor( + private val snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver, + sessionVerificationService: SessionVerificationService, +) { + + private var observingJob: Job? = null + + private val displayLeftRoomMessage = roomMembershipObserver.updates + .map { !it.isUserInRoom } + + private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState + .map { it == VerificationFlowState.Finished } + + fun observeEvents(coroutineScope: CoroutineScope) { + observingJob = coroutineScope.launch { + displayLeftRoomMessage + .filter { it } + .onEach { + displayMessage(CommonStrings.common_current_user_left_room) + } + .launchIn(this) + + displayVerificationSuccessfulMessage + .filter { it } + .onEach { + displayMessage(CommonStrings.common_verification_complete) + }.launchIn(this) + } + } + + fun stopObserving() { + observingJob?.cancel() + observingJob = null + } + + private suspend fun displayMessage(message: Int) { + snackbarDispatcher.post(SnackbarMessage(message)) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt new file mode 100644 index 0000000000..4130e5da23 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -0,0 +1,352 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import coil.Coil +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.replace +import com.bumble.appyx.navmodel.backstack.operation.singleTop +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.loggedin.LoggedInNode +import io.element.android.appnav.room.RoomFlowNode +import io.element.android.appnav.room.RoomLoadedFlowNode +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.invitelist.api.InviteListEntryPoint +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.roomlist.api.RoomListEntryPoint +import io.element.android.features.verifysession.api.VerifySessionEntryPoint +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.deeplink.DeeplinkData +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.MAIN_SPACE +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.ui.di.MatrixUIBindings +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +class LoggedInFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val roomListEntryPoint: RoomListEntryPoint, + private val preferencesEntryPoint: PreferencesEntryPoint, + private val createRoomEntryPoint: CreateRoomEntryPoint, + private val appNavigationStateService: AppNavigationStateService, + private val verifySessionEntryPoint: VerifySessionEntryPoint, + private val inviteListEntryPoint: InviteListEntryPoint, + private val ftueEntryPoint: FtueEntryPoint, + private val coroutineScope: CoroutineScope, + private val networkMonitor: NetworkMonitor, + private val notificationDrawerManager: NotificationDrawerManager, + private val ftueState: FtueState, + snackbarDispatcher: SnackbarDispatcher, +) : BackstackNode<LoggedInFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.RoomList, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + + interface Callback : Plugin { + fun onOpenBugReport() = Unit + } + + interface LifecycleCallback : NodeLifecycleCallback { + fun onFlowCreated(identifier: String, client: MatrixClient) = Unit + + fun onFlowReleased(identifier: String, client: MatrixClient) = Unit + } + + data class Inputs( + val matrixClient: MatrixClient + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val syncService = inputs.matrixClient.syncService() + private val loggedInFlowProcessor = LoggedInEventProcessor( + snackbarDispatcher, + inputs.matrixClient.roomMembershipObserver(), + inputs.matrixClient.sessionVerificationService(), + ) + + override fun onBuilt() { + super.onBuilt() + + lifecycle.subscribe( + onCreate = { + plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) } + val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory() + Coil.setImageLoader(imageLoaderFactory) + appNavigationStateService.onNavigateToSession(id, inputs.matrixClient.sessionId) + // TODO We do not support Space yet, so directly navigate to main space + appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) + loggedInFlowProcessor.observeEvents(coroutineScope) + + if (ftueState.shouldDisplayFlow.value) { + backstack.push(NavTarget.Ftue) + } + }, + onResume = { + lifecycleScope.launch { + syncService.startSync() + } + }, + onPause = { + syncService.stopSync() + }, + onDestroy = { + plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) } + appNavigationStateService.onLeavingSpace(id) + appNavigationStateService.onLeavingSession(id) + loggedInFlowProcessor.stopObserving() + } + ) + + observeSyncStateAndNetworkStatus() + } + + private fun observeSyncStateAndNetworkStatus() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + combine( + syncService.syncState, + networkMonitor.connectivity + ) { syncState, networkStatus -> + syncState == SyncState.Error && networkStatus == NetworkStatus.Online + } + .distinctUntilChanged() + .collect { restartSync -> + if (restartSync) { + syncService.startSync() + } + } + } + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Permanent : NavTarget + + @Parcelize + object RoomList : NavTarget + + @Parcelize + data class Room( + val roomId: RoomId, + val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages + ) : NavTarget + + @Parcelize + object Settings : NavTarget + + @Parcelize + object CreateRoom : NavTarget + + @Parcelize + object VerifySession : NavTarget + + @Parcelize + object InviteList : NavTarget + + @Parcelize + object Ftue : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Permanent -> { + createNode<LoggedInNode>(buildContext) + } + NavTarget.RoomList -> { + val callback = object : RoomListEntryPoint.Callback { + override fun onRoomClicked(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId)) + } + + override fun onSettingsClicked() { + backstack.push(NavTarget.Settings) + } + + override fun onCreateRoomClicked() { + backstack.push(NavTarget.CreateRoom) + } + + override fun onSessionVerificationClicked() { + backstack.push(NavTarget.VerifySession) + } + + override fun onInvitesClicked() { + backstack.push(NavTarget.InviteList) + } + + override fun onRoomSettingsClicked(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomDetails)) + } + + override fun onReportBugClicked() { + plugins<Callback>().forEach { it.onOpenBugReport() } + } + } + roomListEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + is NavTarget.Room -> { + val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() + val callback = object : RoomLoadedFlowNode.Callback { + override fun onForwardedToSingleRoom(roomId: RoomId) { + coroutineScope.launch { attachRoom(roomId) } + } + } + val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement) + createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) + } + NavTarget.Settings -> { + val callback = object : PreferencesEntryPoint.Callback { + override fun onOpenBugReport() { + plugins<Callback>().forEach { it.onOpenBugReport() } + } + + override fun onVerifyClicked() { + backstack.push(NavTarget.VerifySession) + } + } + preferencesEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + NavTarget.CreateRoom -> { + val callback = object : CreateRoomEntryPoint.Callback { + override fun onSuccess(roomId: RoomId) { + backstack.replace(NavTarget.Room(roomId)) + } + } + + createRoomEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + NavTarget.VerifySession -> { + verifySessionEntryPoint.createNode(this, buildContext) + } + NavTarget.InviteList -> { + val callback = object : InviteListEntryPoint.Callback { + override fun onBackClicked() { + backstack.pop() + } + + override fun onInviteAccepted(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId)) + } + } + + inviteListEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + NavTarget.Ftue -> { + ftueEntryPoint.nodeBuilder(this, buildContext) + .callback(object : FtueEntryPoint.Callback { + override fun onFtueFlowFinished() { + backstack.pop() + } + }).build() + } + } + } + + suspend fun attachRoot(): Node { + return attachChild { + backstack.singleTop(NavTarget.RoomList) + } + } + + suspend fun attachRoom(roomId: RoomId): RoomFlowNode { + return attachChild { + backstack.singleTop(NavTarget.RoomList) + backstack.push(NavTarget.Room(roomId)) + } + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + Children( + navModel = backstack, + modifier = Modifier, + // Animate navigation to settings and to a room + transitionHandler = rememberDefaultTransitionHandler(), + ) + + val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() + + if (!isFtueDisplayed) { + PermanentChild(navTarget = NavTarget.Permanent) + } + } + } + + internal suspend fun attachRoom(deeplinkData: DeeplinkData.Room) { + backstack.push(NavTarget.Room(deeplinkData.roomId)) + } + + internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) { + notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId) + backstack.push(NavTarget.InviteList) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt b/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt new file mode 100644 index 0000000000..a06564c3aa --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/NodeLifecycleCallback.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import com.bumble.appyx.core.plugin.Plugin + +interface NodeLifecycleCallback : Plugin diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt new file mode 100644 index 0000000000..1ed1aec678 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coil.Coil +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.onboarding.api.OnBoardingEntryPoint +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +class NotLoggedInFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val onBoardingEntryPoint: OnBoardingEntryPoint, + private val loginEntryPoint: LoginEntryPoint, + private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory, +) : BackstackNode<NotLoggedInFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.OnBoarding, + savedStateMap = buildContext.savedStateMap + ), + buildContext = buildContext, + plugins = plugins, +) { + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onCreate = { + Coil.setImageLoader(notLoggedInImageLoaderFactory) + }, + ) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object OnBoarding : NavTarget + + @Parcelize + data class LoginFlow( + val isAccountCreation: Boolean, + ) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.OnBoarding -> { + val callback = object : OnBoardingEntryPoint.Callback { + override fun onSignUp() { + backstack.push(NavTarget.LoginFlow(isAccountCreation = true)) + } + + override fun onSignIn() { + backstack.push(NavTarget.LoginFlow(isAccountCreation = false)) + } + } + onBoardingEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + is NavTarget.LoginFlow -> { + loginEntryPoint.nodeBuilder(this, buildContext) + .params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation)) + .build() + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + // Animate navigation to login screen + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt new file mode 100644 index 0000000000..089e956c61 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.di.MatrixClientsHolder +import io.element.android.appnav.intent.IntentResolver +import io.element.android.appnav.intent.ResolvedIntent +import io.element.android.appnav.root.RootNavStateFlowFactory +import io.element.android.appnav.root.RootPresenter +import io.element.android.appnav.root.RootView +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.api.oidc.OidcActionFlow +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.deeplink.DeeplinkData +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(AppScope::class) +class RootFlowNode @AssistedInject constructor( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val authenticationService: MatrixAuthenticationService, + private val navStateFlowFactory: RootNavStateFlowFactory, + private val matrixClientsHolder: MatrixClientsHolder, + private val presenter: RootPresenter, + private val bugReportEntryPoint: BugReportEntryPoint, + private val intentResolver: IntentResolver, + private val oidcActionFlow: OidcActionFlow, +) : + BackstackNode<RootFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.SplashScreen, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + + override fun onBuilt() { + matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap) + super.onBuilt() + observeNavState() + } + + override fun onSaveInstanceState(state: MutableSavedStateMap) { + super.onSaveInstanceState(state) + matrixClientsHolder.saveIntoSavedState(state) + navStateFlowFactory.saveIntoSavedState(state) + } + + private fun observeNavState() { + navStateFlowFactory.create(buildContext.savedStateMap) + .distinctUntilChanged() + .onEach { navState -> + Timber.v("navState=$navState") + if (navState.isLoggedIn) { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow() } + ) + } else { + switchToNotLoggedInFlow() + } + } + .launchIn(lifecycleScope) + } + + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + } + + private fun switchToNotLoggedInFlow() { + matrixClientsHolder.removeAll() + backstack.safeRoot(NavTarget.NotLoggedInFlow) + } + + private suspend fun restoreSessionIfNeeded( + sessionId: SessionId, + onFailure: () -> Unit = {}, + onSuccess: (SessionId) -> Unit = {}, + ) { + matrixClientsHolder.getOrRestore(sessionId) + .onSuccess { + Timber.v("Succeed to restore session $sessionId") + onSuccess(sessionId) + } + .onFailure { + Timber.v("Failed to restore session $sessionId") + onFailure() + } + } + + private suspend fun tryToRestoreLatestSession( + onSuccess: (SessionId) -> Unit = {}, + onFailure: () -> Unit = {} + ) { + val latestSessionId = authenticationService.getLatestSessionId() + if (latestSessionId == null) { + onFailure() + return + } + restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess) + } + + private fun onOpenBugReport() { + backstack.push(NavTarget.BugReport) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RootView( + state = state, + modifier = modifier, + onOpenBugReport = this::onOpenBugReport, + ) { + Children( + navModel = backstack, + // Animate opening the bug report screen + transitionHandler = rememberDefaultTransitionHandler(), + ) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object SplashScreen : NavTarget + + @Parcelize + object NotLoggedInFlow : NavTarget + + @Parcelize + data class LoggedInFlow( + val sessionId: SessionId, + val navId: Int + ) : NavTarget + + @Parcelize + object BugReport : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.LoggedInFlow -> { + val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { + Timber.w("Couldn't find any session, go through SplashScreen") + } + val inputs = LoggedInFlowNode.Inputs(matrixClient) + val callback = object : LoggedInFlowNode.Callback { + override fun onOpenBugReport() { + backstack.push(NavTarget.BugReport) + } + } + val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() + createNode<LoggedInFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) + } + NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext) + NavTarget.SplashScreen -> splashNode(buildContext) + NavTarget.BugReport -> { + val callback = object : BugReportEntryPoint.Callback { + override fun onBugReportSent() { + backstack.pop() + } + } + bugReportEntryPoint + .nodeBuilder(this, buildContext) + .callback(callback) + .build() + } + } + } + + private fun splashNode(buildContext: BuildContext) = node(buildContext) { + Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + suspend fun handleIntent(intent: Intent) { + val resolvedIntent = intentResolver.resolve(intent) ?: return + when (resolvedIntent) { + is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData) + is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) + } + } + + private suspend fun navigateTo(deeplinkData: DeeplinkData) { + Timber.d("Navigating to $deeplinkData") + attachSession(deeplinkData.sessionId) + .apply { + when (deeplinkData) { + is DeeplinkData.Root -> attachRoot() + is DeeplinkData.Room -> attachRoom(deeplinkData) + is DeeplinkData.InviteList -> attachInviteList(deeplinkData) + } + } + } + + private fun onOidcAction(oidcAction: OidcAction) { + oidcActionFlow.post(oidcAction) + } + + private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { + //TODO handle multi-session + return waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId + } + } +} + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt new file mode 100644 index 0000000000..3e36e7d692 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.di + +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider { + + private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>() + private val restoreMutex = Mutex() + + fun removeAll() { + sessionIdsToMatrixClient.clear() + } + + fun remove(sessionId: SessionId) { + sessionIdsToMatrixClient.remove(sessionId) + } + + fun getOrNull(sessionId: SessionId): MatrixClient? { + return sessionIdsToMatrixClient[sessionId] + } + + override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> { + return restoreMutex.withLock { + when (val matrixClient = getOrNull(sessionId)) { + null -> restore(sessionId) + else -> Result.success(matrixClient) + } + } + } + + @Suppress("UNCHECKED_CAST") + fun restoreWithSavedState(state: SavedStateMap?) { + Timber.d("Restore state") + if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { + Timber.w("Restore with non-empty map") + } + val sessionIds = state[SAVE_INSTANCE_KEY] as? Array<SessionId> + Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") + if (sessionIds.isNullOrEmpty()) return + // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. + runBlocking { + sessionIds.forEach { sessionId -> + restore(sessionId) + } + } + } + + fun saveIntoSavedState(state: MutableSavedStateMap) { + val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() + Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") + state[SAVE_INSTANCE_KEY] = sessionKeys + } + + private suspend fun restore(sessionId: SessionId): Result<MatrixClient> { + Timber.d("Restore matrix session: $sessionId") + return authenticationService.restoreSession(sessionId) + .onSuccess { matrixClient -> + sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient + } + .onFailure { + Timber.e("Fail to restore session") + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt new file mode 100644 index 0000000000..6a3d8ff9dd --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.intent + +import android.content.Intent +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.api.oidc.OidcIntentResolver +import io.element.android.libraries.deeplink.DeeplinkData +import io.element.android.libraries.deeplink.DeeplinkParser +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import timber.log.Timber +import javax.inject.Inject + +sealed interface ResolvedIntent { + data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent + data class Oidc(val oidcAction: OidcAction) : ResolvedIntent +} + +class IntentResolver @Inject constructor( + private val deeplinkParser: DeeplinkParser, + private val oidcIntentResolver: OidcIntentResolver +) { + fun resolve(intent: Intent): ResolvedIntent? { + val deepLinkData = deeplinkParser.getFromIntent(intent) + if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData) + + val oidcAction = oidcIntentResolver.resolve(intent) + if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction) + + // Unknown intent + Timber.w("Unknown intent") + return null + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt new file mode 100644 index 0000000000..664ec1f663 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +// sealed interface LoggedInEvents { +// object MyEvent : LoggedInEvents +// } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt new file mode 100644 index 0000000000..6950b9b699 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class LoggedInNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val loggedInPresenter: LoggedInPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val loggedInState = loggedInPresenter.present() + LoggedInView( + state = loggedInState, + modifier = modifier + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt new file mode 100644 index 0000000000..8910cc3976 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.push.api.PushService +import javax.inject.Inject + +class LoggedInPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val permissionsPresenterFactory: PermissionsPresenter.Factory, + private val pushService: PushService, +) : Presenter<LoggedInState> { + + private val postNotificationPermissionsPresenter by lazy { + // Ask for POST_NOTIFICATION PERMISSION on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) + } else { + NoopPermissionsPresenter() + } + } + + @Composable + override fun present(): LoggedInState { + LaunchedEffect(Unit) { + // Ensure pusher is registered + // TODO Manually select push provider for now + val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect + val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, pushProvider, distributor) + } + + val syncState = matrixClient.syncService().syncState.collectAsState() + val permissionsState = postNotificationPermissionsPresenter.present() + + // fun handleEvents(event: LoggedInEvents) { + // when (event) { + // } + // } + + return LoggedInState( + syncState = syncState.value, + permissionsState = permissionsState, + // eventSink = ::handleEvents + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt new file mode 100644 index 0000000000..075242cddb --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.permissions.api.PermissionsState + +data class LoggedInState( + val syncState: SyncState, + val permissionsState: PermissionsState, + // val eventSink: (LoggedInEvents) -> Unit +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt new file mode 100644 index 0000000000..e8a8a4762c --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState + +open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> { + override val values: Sequence<LoggedInState> + get() = sequenceOf( + aLoggedInState(), + aLoggedInState(syncState = SyncState.Idle), + // Add other state here + ) +} + +fun aLoggedInState( + syncState: SyncState = SyncState.Running, +) = LoggedInState( + syncState = syncState, + permissionsState = createDummyPostNotificationPermissionsState(), + // eventSink = {} +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt new file mode 100644 index 0000000000..60784ea4ed --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.permissions.api.PermissionsView + +@Composable +fun LoggedInView( + state: LoggedInState, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + SyncStateView( + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.TopCenter), + syncState = state.syncState, + ) + PermissionsView( + state = state.permissionsState, + openSystemSettings = context::openAppSettingsPage + ) + } +} + +@DayNightPreviews +@Composable +fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { + LoggedInView( + state = state + ) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt new file mode 100644 index 0000000000..5108bb8716 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SyncStateView( + syncState: SyncState, + modifier: Modifier = Modifier +) { + val animationSpec = spring<Float>(stiffness = 500F) + AnimatedVisibility( + modifier = modifier, + visible = syncState.mustBeVisible(), + enter = fadeIn(animationSpec = animationSpec), + exit = fadeOut(animationSpec = animationSpec), + ) { + Surface( + shape = RoundedCornerShape(24.dp), + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier + .background(color = ElementTheme.colors.bgSubtleSecondary) + .padding(horizontal = 24.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(12.dp), + color = ElementTheme.colors.textPrimary, + strokeWidth = 1.5.dp, + ) + Text( + text = stringResource(id = CommonStrings.common_syncing), + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdMedium + ) + } + } + } +} + +private fun SyncState.mustBeVisible() = when (this) { + SyncState.Idle -> true /* Cold start of the app */ + SyncState.Running -> false + SyncState.Error -> false /* In this case, the network error banner can be displayed */ + SyncState.Terminated -> true /* The app is resumed and the sync is started again */ +} + +@DayNightPreviews +@Composable +fun SyncStateViewPreview() = ElementPreview { + // Add a box to see the shadow + Box(modifier = Modifier.padding(24.dp)) { + SyncStateView( + syncState = SyncState.Idle + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt new file mode 100644 index 0000000000..e8d68a3e94 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.room + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LoadingRoomNodeView( + state: LoadingRoomState, + hasNetworkConnection: Boolean, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + Column { + ConnectivityIndicatorView(isOnline = hasNetworkConnection) + LoadingRoomTopBar(onBackClicked) + } + }, + content = { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + if (state is LoadingRoomState.Error) { + Text( + text = stringResource(id = CommonStrings.error_unknown), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } else { + CircularProgressIndicator() + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoadingRoomTopBar( + onBackClicked: () -> Unit, + modifier: Modifier = Modifier +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClicked) + }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(AvatarSize.TimelineRoom.dp) + .align(Alignment.CenterVertically) + .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + PlaceholderAtom(width = 20.dp, height = 7.dp) + Spacer(modifier = Modifier.width(7.dp)) + PlaceholderAtom(width = 45.dp, height = 7.dp) + } + }, + windowInsets = WindowInsets(0.dp), + ) +} + +@Preview +@Composable +fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LoadingRoomState) { + LoadingRoomNodeView( + state = state, + onBackClicked = {}, + hasNetworkConnection = false + ) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt new file mode 100644 index 0000000000..db4627c3b4 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomState.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.room + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +sealed interface LoadingRoomState { + object Loading : LoadingRoomState + object Error : LoadingRoomState + data class Loaded(val room: MatrixRoom) : LoadingRoomState +} + +open class LoadingRoomStateProvider : PreviewParameterProvider<LoadingRoomState> { + override val values: Sequence<LoadingRoomState> + get() = sequenceOf( + LoadingRoomState.Loading, + LoadingRoomState.Error + ) +} + +@SingleIn(SessionScope::class) +class LoadingRoomStateFlowFactory @Inject constructor(private val matrixClient: MatrixClient) { + + fun create(lifecycleScope: CoroutineScope, roomId: RoomId): StateFlow<LoadingRoomState> = + getRoomFlow(roomId) + .map { room -> + if (room != null) { + LoadingRoomState.Loaded(room) + } else { + LoadingRoomState.Error + } + } + .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading) + + private fun getRoomFlow(roomId: RoomId): Flow<MatrixRoom?> = suspend { + matrixClient.getRoom(roomId = roomId) + } + .asFlow() +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt new file mode 100644 index 0000000000..20ec9f48b4 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.appnav.room + +import android.os.Parcelable +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.NodeLifecycleCallback +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class RoomFlowNode @AssistedInject constructor( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, + private val networkMonitor: NetworkMonitor, +) : + BackstackNode<RoomFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + + data class Inputs( + val roomId: RoomId, + val initialElement: RoomLoadedFlowNode.NavTarget = RoomLoadedFlowNode.NavTarget.Messages, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId) + + sealed interface NavTarget : Parcelable { + @Parcelize + object Loading : NavTarget + + @Parcelize + object Loaded : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + loadingRoomStateStateFlow + .map { + it is LoadingRoomState.Loaded + } + .distinctUntilChanged() + .onEach { isLoaded -> + if (isLoaded) { + backstack.newRoot(NavTarget.Loaded) + } else { + backstack.newRoot(NavTarget.Loading) + } + }.launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Loaded -> { + val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() + val roomFlowNodeCallback = plugins<RoomLoadedFlowNode.Callback>() + val awaitRoomState = loadingRoomStateStateFlow.value + if (awaitRoomState is LoadingRoomState.Loaded) { + val inputs = RoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) + createNode<RoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback + nodeLifecycleCallbacks) + } else { + loadingNode(buildContext, this::navigateUp) + } + } + NavTarget.Loading -> { + loadingNode(buildContext, this::navigateUp) + } + } + } + + private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> + val loadingRoomState by loadingRoomStateStateFlow.collectAsState() + val networkStatus by networkMonitor.connectivity.collectAsState() + LoadingRoomNodeView( + state = loadingRoomState, + hasNetworkConnection = networkStatus == NetworkStatus.Online, + modifier = modifier, + onBackClicked = onBackClicked + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + ) + } +} + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt new file mode 100644 index 0000000000..73a8579b07 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.room + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.appnav.NodeLifecycleCallback +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(SessionScope::class) +class RoomLoadedFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val messagesEntryPoint: MessagesEntryPoint, + private val roomDetailsEntryPoint: RoomDetailsEntryPoint, + private val appNavigationStateService: AppNavigationStateService, + roomMembershipObserver: RoomMembershipObserver, +) : BackstackNode<RoomLoadedFlowNode.NavTarget>( + backstack = BackStack( + initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialElement, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + + interface Callback : Plugin { + fun onForwardedToSingleRoom(roomId: RoomId) + } + + interface LifecycleCallback : NodeLifecycleCallback { + fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit + fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit + } + + data class Inputs( + val room: MatrixRoom, + val initialElement: NavTarget = NavTarget.Messages, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val callbacks = plugins.filterIsInstance<Callback>() + + init { + lifecycle.subscribe( + onCreate = { + Timber.v("OnCreate") + plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.room) } + appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) + fetchRoomMembers() + }, + onDestroy = { + Timber.v("OnDestroy") + plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) } + appNavigationStateService.onLeavingRoom(id) + } + ) + roomMembershipObserver.updates + .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom } + .onEach { + navigateUp() + } + .launchIn(lifecycleScope) + inputs<Inputs>() + } + + private fun fetchRoomMembers() = lifecycleScope.launch { + val room = inputs.room + room.updateMembers() + .onFailure { + Timber.e(it, "Fail to fetch members for room ${room.roomId}") + }.onSuccess { + Timber.v("Success fetching members for room ${room.roomId}") + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Messages -> { + val callback = object : MessagesEntryPoint.Callback { + override fun onRoomDetailsClicked() { + backstack.push(NavTarget.RoomDetails) + } + + override fun onUserDataClicked(userId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(userId)) + } + + override fun onForwardedToSingleRoom(roomId: RoomId) { + callbacks.forEach { it.onForwardedToSingleRoom(roomId) } + } + } + messagesEntryPoint.createNode(this, buildContext, callback) + } + NavTarget.RoomDetails -> { + val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails) + roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + } + is NavTarget.RoomMemberDetails -> { + val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) + roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList()) + } + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object Messages : NavTarget + + @Parcelize + object RoomDetails : NavTarget + + @Parcelize + data class RoomMemberDetails(val userId: UserId) : NavTarget + } + + @Composable + override fun View(modifier: Modifier) { + // Rely on the View Lifecycle instead of the Node Lifecycle, + // because this node enters 'onDestroy' before his children, so it can leads to + // using the room in a child node where it's already closed. + DisposableEffect(Unit) { + inputs.room.open() + onDispose { + inputs.room.close() + } + } + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt new file mode 100644 index 0000000000..ed3ac15972 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +/** + * [RootNavState] produced by [RootNavStateFlowFactory]. + */ +data class RootNavState( + /** + * This value is incremented when a clear cache is done. + * Can be useful to track to force ui state to re-render + */ + val cacheIndex: Int, + /** + * true if we are currently loggedIn. + */ + val isLoggedIn: Boolean +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt new file mode 100644 index 0000000000..0e8d93b0c9 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import io.element.android.appnav.di.MatrixClientsHolder +import io.element.android.features.login.api.LoginUserStory +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY" + +/** + * This class is responsible for creating a flow of [RootNavState]. + * It gathers data from multiple datasource and creates a unique one. + */ +class RootNavStateFlowFactory @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val cacheService: CacheService, + private val matrixClientsHolder: MatrixClientsHolder, + private val loginUserStory: LoginUserStory, +) { + + private var currentCacheIndex = 0 + + fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> { + return combine( + cacheIndexFlow(savedStateMap), + isUserLoggedInFlow(), + ) { cacheIndex, isLoggedIn -> + RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn) + } + } + + fun saveIntoSavedState(stateMap: MutableSavedStateMap) { + stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex + } + + /** + * @return a flow of integer, where each time a clear cache is done, we have a new incremented value. + */ + private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow<Int> { + val initialCacheIndex = savedStateMap.getCacheIndexOrDefault() + return cacheService.clearedCacheEventFlow + .onEach { sessionId -> + matrixClientsHolder.remove(sessionId) + } + .toIndexFlow(initialCacheIndex) + .onEach { cacheIndex -> + currentCacheIndex = cacheIndex + } + } + + private fun isUserLoggedInFlow(): Flow<Boolean> { + return combine( + authenticationService.isLoggedIn(), + loginUserStory.loginFlowIsDone + ) { isLoggedIn, loginFlowIsDone -> + isLoggedIn && loginFlowIsDone + } + .distinctUntilChanged() + } + + /** + * @return a flow of integer that increments the value by one each time a new element is emitted upstream. + */ + private fun Flow<Any>.toIndexFlow(initialValue: Int): Flow<Int> = flow { + var index = initialValue + emit(initialValue) + collect { + emit(++index) + } + } + + private fun SavedStateMap?.getCacheIndexOrDefault(): Int { + return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0 + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt new file mode 100644 index 0000000000..cffc4cf35c --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter +import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.services.apperror.api.AppErrorStateService +import javax.inject.Inject + +class RootPresenter @Inject constructor( + private val crashDetectionPresenter: CrashDetectionPresenter, + private val rageshakeDetectionPresenter: RageshakeDetectionPresenter, + private val appErrorStateService: AppErrorStateService, +) : Presenter<RootState> { + + @Composable + override fun present(): RootState { + val rageshakeDetectionState = rageshakeDetectionPresenter.present() + val crashDetectionState = crashDetectionPresenter.present() + val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState() + + return RootState( + rageshakeDetectionState = rageshakeDetectionState, + crashDetectionState = crashDetectionState, + errorState = appErrorState, + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt new file mode 100644 index 0000000000..704adb5df3 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +import androidx.compose.runtime.Immutable +import io.element.android.features.rageshake.api.crash.CrashDetectionState +import io.element.android.features.rageshake.api.detection.RageshakeDetectionState +import io.element.android.services.apperror.api.AppErrorState + +@Immutable +data class RootState( + val rageshakeDetectionState: RageshakeDetectionState, + val crashDetectionState: CrashDetectionState, + val errorState: AppErrorState, +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt new file mode 100644 index 0000000000..c8b5413e60 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.rageshake.api.crash.aCrashDetectionState +import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState +import io.element.android.services.apperror.api.AppErrorState +import io.element.android.services.apperror.api.aAppErrorState + +open class RootStateProvider : PreviewParameterProvider<RootState> { + override val values: Sequence<RootState> + get() = sequenceOf( + aRootState().copy( + rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = false), + crashDetectionState = aCrashDetectionState().copy(crashDetected = true), + ), + aRootState().copy( + rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true), + crashDetectionState = aCrashDetectionState().copy(crashDetected = false), + ), + aRootState().copy( + errorState = aAppErrorState(), + ) + ) +} + +fun aRootState() = RootState( + rageshakeDetectionState = aRageshakeDetectionState(), + crashDetectionState = aCrashDetectionState(), + errorState = AppErrorState.NoError, +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt new file mode 100644 index 0000000000..a52ee59261 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.root + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.rageshake.api.crash.CrashDetectionEvents +import io.element.android.features.rageshake.api.crash.CrashDetectionView +import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents +import io.element.android.features.rageshake.api.detection.RageshakeDetectionView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.services.apperror.impl.AppErrorView + +@Composable +fun RootView( + state: RootState, + modifier: Modifier = Modifier, + onOpenBugReport: () -> Unit = {}, + children: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + children() + + fun onOpenBugReport() { + state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed) + state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss) + onOpenBugReport.invoke() + } + + RageshakeDetectionView( + state = state.rageshakeDetectionState, + onOpenBugReport = ::onOpenBugReport, + ) + CrashDetectionView( + state = state.crashDetectionState, + onOpenBugReport = ::onOpenBugReport, + ) + AppErrorView( + state = state.errorState, + ) + } +} + +@Preview +@Composable +internal fun RootLightPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreviewLight { ContentToPreview(rootState) } + +@Preview +@Composable +internal fun RootDarkPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreviewDark { ContentToPreview(rootState) } + +@Composable +private fun ContentToPreview(rootState: RootState) { + RootView(rootState) { + Text("Children") + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt new file mode 100644 index 0000000000..48efd77808 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper +import com.google.common.truth.Truth +import io.element.android.appnav.room.RoomLoadedFlowNode +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.libraries.architecture.childNode +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import org.junit.Rule +import org.junit.Test + +class RoomFlowNodeTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private class FakeMessagesEntryPoint : MessagesEntryPoint { + + var nodeId: String? = null + var callback: MessagesEntryPoint.Callback? = null + + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node { + return node(buildContext) {}.also { + nodeId = it.id + this.callback = callback + } + } + } + + private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint { + + var nodeId: String? = null + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: RoomDetailsEntryPoint.Inputs, + plugins: List<Plugin> + ): Node { + return node(buildContext) {}.also { + nodeId = it.id + } + } + } + + private fun aRoomFlowNode( + plugins: List<Plugin>, + messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), + roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), + ) = RoomLoadedFlowNode( + buildContext = BuildContext.root(savedStateMap = null), + plugins = plugins, + messagesEntryPoint = messagesEntryPoint, + roomDetailsEntryPoint = roomDetailsEntryPoint, + appNavigationStateService = FakeAppNavigationStateService(), + roomMembershipObserver = RoomMembershipObserver() + ) + + @Test + fun `given a room flow node when initialized then it loads messages entry point`() { + // GIVEN + val room = FakeMatrixRoom() + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val inputs = RoomLoadedFlowNode.Inputs(room) + val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint) + // WHEN + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + + // THEN + Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomLoadedFlowNode.NavTarget.Messages) + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) + val messagesNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.Messages)!! + Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) + } + + @Test + fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() { + // GIVEN + val room = FakeMatrixRoom() + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = RoomLoadedFlowNode.Inputs(room) + val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint) + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + // WHEN + fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() + // THEN + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) + val roomDetailsNode = roomFlowNode.childNode(RoomLoadedFlowNode.NavTarget.RoomDetails)!! + Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt new file mode 100644 index 0000000000..0efa9e7f3b --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.root.RootPresenter +import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter +import io.element.android.features.rageshake.impl.detection.DefaultRageshakeDetectionPresenter +import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter +import io.element.android.features.rageshake.test.crash.FakeCrashDataStore +import io.element.android.features.rageshake.test.rageshake.FakeRageShake +import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore +import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.services.apperror.api.AppErrorState +import io.element.android.services.apperror.api.AppErrorStateService +import io.element.android.services.apperror.impl.DefaultAppErrorStateService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetectionState.crashDetected).isFalse() + } + } + + @Test + fun `present - passes app error state`() = runTest { + val presenter = createPresenter( + appErrorService = DefaultAppErrorStateService().apply { + showError("Bad news", "Something bad happened") + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java) + val initialErrorState = initialState.errorState as AppErrorState.Error + assertThat(initialErrorState.title).isEqualTo("Bad news") + assertThat(initialErrorState.body).isEqualTo("Something bad happened") + + initialErrorState.dismiss() + assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java) + } + } + + private fun createPresenter( + appErrorService: AppErrorStateService = DefaultAppErrorStateService() + ): RootPresenter { + val crashDataStore = FakeCrashDataStore() + val rageshakeDataStore = FakeRageshakeDataStore() + val rageshake = FakeRageShake() + val screenshotHolder = FakeScreenshotHolder() + val crashDetectionPresenter = DefaultCrashDetectionPresenter( + crashDataStore = crashDataStore + ) + val rageshakeDetectionPresenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + return RootPresenter( + crashDetectionPresenter = crashDetectionPresenter, + rageshakeDetectionPresenter = rageshakeDetectionPresenter, + appErrorStateService = appErrorService, + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt new file mode 100644 index 0000000000..83bda0ad82 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.loggedin + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoggedInPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionsState.permission).isEmpty() + } + } + + private fun createPresenter(): LoggedInPresenter { + return LoggedInPresenter( + matrixClient = FakeMatrixClient(), + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permission: String): PermissionsPresenter { + return NoopPermissionsPresenter() + } + }, + pushService = object : PushService { + override fun notificationStyleChanged() { + } + + override fun getAvailablePushProviders(): List<PushProvider> { + return emptyList() + } + + override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + } + + override suspend fun testPush() { + } + } + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt new file mode 100644 index 0000000000..17b6f6deb9 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.appnav.room + +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoadingRoomStateFlowFactoryTest { + + @Test + fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest { + val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID) + val matrixClient = FakeMatrixClient(A_SESSION_ID).apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(this, A_ROOM_ID) + .test { + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) + } + } + + @Test + fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest { + val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID) + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource) + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(this, A_ROOM_ID) + .test { + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + matrixClient.givenGetRoomResult(A_ROOM_ID, room) + roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1)) + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) + } + } + + @Test + fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource) + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(this, A_ROOM_ID) + .test { + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1)) + Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) + } + } + + + +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000..c03881144e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,337 @@ +import kotlinx.kover.api.KoverTaskExtension +import org.jetbrains.kotlin.cli.common.toBooleanLenient + +buildscript { + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") + classpath("com.google.gms:google-services:4.3.15") + } +} + +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.anvil) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kapt) apply false + alias(libs.plugins.dependencycheck) apply false + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + alias(libs.plugins.dependencygraph) + alias(libs.plugins.sonarqube) + alias(libs.plugins.kover) +} + +tasks.register<Delete>("clean").configure { + delete(rootProject.buildDir) +} + +allprojects { + // Detekt + apply { + plugin("io.gitlab.arturbosch.detekt") + } + detekt { + // preconfigure defaults + buildUponDefaultConfig = true + // activate all available (even unstable) rules. + allRules = true + // point to your custom config defining rules to run, overwriting default behavior + config = files("$rootDir/tools/detekt/detekt.yml") + } + dependencies { + detektPlugins("io.nlopez.compose.rules:detekt:0.1.12") + } + + // KtLint + apply { + plugin("org.jlleitschuh.gradle.ktlint") + } + + // See https://github.com/JLLeitschuh/ktlint-gradle#configuration + configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { + // See https://github.com/pinterest/ktlint/releases/ + // TODO Regularly check for new version here ^ + version.set("0.48.2") + android.set(true) + ignoreFailures.set(false) + enableExperimentalRules.set(true) + // display the corresponding rule + verbose.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + // To have XML report for Danger + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } + filter { + exclude { element -> element.file.path.contains("$buildDir/generated/") } + } + } + // Dependency check + apply { + plugin("org.owasp.dependencycheck") + } +} + +// To run a sonar analysis: +// Run './gradlew sonar -Dsonar.login=<SONAR_LOGIN>' +// The SONAR_LOGIN is stored in passbolt as Token Sonar Cloud Bma +// Sonar result can be found here: https://sonarcloud.io/project/overview?id=vector-im_element-x-android +sonar { + properties { + property("sonar.projectName", "element-x-android") + property("sonar.projectKey", "vector-im_element-x-android") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.projectVersion", "1.0") // TODO project(":app").android.defaultConfig.versionName) + property("sonar.sourceEncoding", "UTF-8") + property("sonar.links.homepage", "https://github.com/vector-im/element-x-android/") + property("sonar.links.ci", "https://github.com/vector-im/element-x-android/actions") + property("sonar.links.scm", "https://github.com/vector-im/element-x-android/") + property("sonar.links.issue", "https://github.com/vector-im/element-x-android/issues") + property("sonar.organization", "new_vector_ltd_organization") + property("sonar.login", if (project.hasProperty("SONAR_LOGIN")) project.property("SONAR_LOGIN")!! else "invalid") + + // exclude source code from analyses separated by a colon (:) + // Exclude Java source + property("sonar.exclusions", "**/BugReporterMultipartBody.java") + } +} + +allprojects { + val projectDir = projectDir.toString() + sonar { + properties { + // Note: folders `kotlin` are not supported (yet), I asked on their side: https://community.sonarsource.com/t/82824 + // As a workaround provide the path in `sonar.sources` property. + if (File("$projectDir/src/main/kotlin").exists()) { + property("sonar.sources", "src/main/kotlin") + } + if (File("$projectDir/src/test/kotlin").exists()) { + property("sonar.tests", "src/test/kotlin") + } + } + } +} + +allprojects { + tasks.withType<Test> { + maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } + if (isScreenshotTest) { + // Increase heap size for screenshot tests + maxHeapSize = "1g" + } else { + // Disable screenshot tests by default + exclude("**/ScreenshotTest*") + } + } +} + +allprojects { + apply(plugin = "kover") +} + +// https://kotlin.github.io/kotlinx-kover/ +// Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover +// Run `./gradlew koverMergedReport` to also get XML report +koverMerged { + enable() + + filters { + classes { + excludes.addAll( + listOf( + // Exclude generated classes. + "*_ModuleKt", + "anvil.hint.binding.io.element.*", + "anvil.hint.merge.*", + "anvil.module.*", + "com.airbnb.android.showkase*", + "io.element.android.libraries.designsystem.showkase.*", + "*_Factory", + "*_Factory$*", + "*_Module", + "*_Module$*", + "*Module_Provides*", + "Dagger*Component*", + "*ComposableSingletons$*", + "*_AssistedFactory_Impl*", + "*BuildConfig", + // Generated by Showkase + "*Ioelementandroid*PreviewKt$*", + "*Ioelementandroid*PreviewKt", + // Other + // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) + "*Node", + "*Node$*", + ) + ) + } + + annotations { + excludes.addAll( + listOf( + "*Preview", + ) + ) + } + + projects { + excludes.addAll( + listOf( + ":anvilannotations", + ":anvilcodegen", + ":samples:minimal", + ":tests:testutils", + ) + ) + } + } + + // Run ./gradlew koverMergedVerify to check the rules. + verify { + // Does not seems to work, so also run the task manually on the workflow. + onCheck.set(true) + // General rule: minimum code coverage. + rule { + name = "Global minimum code coverage." + target = kotlinx.kover.api.VerificationTarget.ALL + bound { + minValue = 55 + // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. + // For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update + // minValue to 25 and maxValue to 35. + maxValue = 65 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + // Rule to ensure that coverage of Presenters is sufficient. + rule { + name = "Check code coverage of presenters" + target = kotlinx.kover.api.VerificationTarget.CLASS + overrideClassFilter { + includes += "*Presenter" + excludes += "*Fake*Presenter" + excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" + } + bound { + minValue = 85 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + // Rule to ensure that coverage of States is sufficient. + rule { + name = "Check code coverage of states" + target = kotlinx.kover.api.VerificationTarget.CLASS + overrideClassFilter { + includes += "^*State$" + excludes += "io.element.android.appnav.root.RootNavState*" + excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*" + excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*" + excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*" + excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*" + excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*" + excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState" + excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState" + excludes += "io.element.android.features.location.impl.map.MapState*" + excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*" + excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*" + excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*" + excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*" + excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*" + excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState" + excludes += "io.element.android.libraries.maplibre.compose.SymbolState*" + excludes += "io.element.android.features.ftue.api.state.*" + excludes += "io.element.android.features.ftue.impl.welcome.state.*" + } + bound { + minValue = 90 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + // Rule to ensure that coverage of Views is sufficient (deactivated for now). + rule { + name = "Check code coverage of views" + target = kotlinx.kover.api.VerificationTarget.CLASS + overrideClassFilter { + includes += "*ViewKt" + } + bound { + // TODO Update this value, for now there are too many missing tests. + minValue = 0 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + } +} + +// When running on the CI, run only debug test variants +val ciBuildProperty = "ci-build" +val isCiBuild = if (project.hasProperty(ciBuildProperty)) { + val raw = project.property(ciBuildProperty) as? String + raw?.toBooleanLenient() == true || raw?.toIntOrNull() == 1 +} else { + false +} +if (isCiBuild) { + allprojects { + afterEvaluate { + tasks.withType<Test>().configureEach { + extensions.configure<KoverTaskExtension> { + val enabled = name.contains("debug", ignoreCase = true) + isDisabled.set(!enabled) + } + } + } + } +} + +// Register quality check tasks. +tasks.register("runQualityChecks") { + project.subprojects { + // For some reason `findByName("lint")` doesn't work + tasks.findByPath("$path:lint")?.let { dependsOn(it) } + tasks.findByName("detekt")?.let { dependsOn(it) } + tasks.findByName("ktlintCheck")?.let { dependsOn(it) } + } + dependsOn(":app:knitCheck") +} + +// Make sure to delete old screenshots before recording new ones +subprojects { + val snapshotsDir = File("${project.projectDir}/src/test/snapshots") + val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") { + onlyIf { snapshotsDir.exists() } + doFirst { + println("Delete previous screenshots located at $snapshotsDir\n") + snapshotsDir.deleteRecursively() + } + } + tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask) + tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask) + tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) +} diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore new file mode 100644 index 0000000000..b722e9e13e --- /dev/null +++ b/changelog.d/.gitignore @@ -0,0 +1 @@ +!.gitignore \ No newline at end of file diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md new file mode 100644 index 0000000000..9198137577 --- /dev/null +++ b/docs/_developer_onboarding.md @@ -0,0 +1,437 @@ +# Developer on boarding + +<!--- TOC --> + +* [Introduction](#introduction) + * [Quick introduction to Matrix](#quick-introduction-to-matrix) + * [Matrix data](#matrix-data) + * [Room](#room) + * [Event](#event) + * [Sync](#sync) + * [Rust SDK](#rust-sdk) + * [Matrix Rust Component Kotlin](#matrix-rust-component-kotlin) + * [Build the SDK locally](#build-the-sdk-locally) + * [The Android project](#the-android-project) + * [Application](#application) + * [Jetpack Compose](#jetpack-compose) + * [Global architecture](#global-architecture) + * [Template and naming](#template-and-naming) + * [Push](#push) + * [Dependencies management](#dependencies-management) + * [Test](#test) + * [Code coverage](#code-coverage) + * [Other points](#other-points) + * [Logging](#logging) + * [Translations](#translations) + * [Rageshake](#rageshake) + * [Tips](#tips) +* [Happy coding!](#happy-coding) + +<!--- END --> + +## Introduction + +This doc is a quick introduction about the project and its architecture. + +It's aim is to help new developers to understand the overall project and where to start developing. + +Other useful documentation: + +- all the docs in this folder! +- the [contributing doc](../CONTRIBUTING.md), that you should also read carefully. + +### Quick introduction to Matrix + +Matrix website: [matrix.org](https://matrix.org), [discover page](https://matrix.org/discover). +*Note*: Matrix.org is also hosting a homeserver ([.well-known file](https://matrix.org/.well-known/matrix/client)). +The reference homeserver (this is how Matrix servers are called) implementation is [Synapse](https://github.com/matrix-org/synapse/). But other implementations +exist. The Matrix specification is here to ensure that any Matrix client, such as Element Android and its SDK can talk to any Matrix server. + +Have a quick look to the client-server API documentation: [Client-server documentation](https://spec.matrix.org/v1.3/client-server-api/). Other network API +exist, the list is here: (https://spec.matrix.org/latest/) + +Matrix is an open source protocol. Change are possible and are tracked using [this GitHub repository](https://github.com/matrix-org/matrix-doc/). Changes to the +protocol are called MSC: Matrix Spec Change. These are PullRequest to this project. + +Matrix object are Json data. Unstable prefixes must be used for Json keys when the MSC is not merged (i.e. accepted). + +#### Matrix data + +There are many object and data in the Matrix worlds. Let's focus on the most important and used, `Room` and `Event` + +##### Room + +`Room` is a place which contains ordered `Event`s. They are identified with their `room_id`. Nearly all the data are stored in rooms, and shared using +homeserver to all the Room Member. + +*Note*: Spaces are also Rooms with a different `type`. + +##### Event + +`Events` are items of a Room, where data is embedded. + +There are 2 types of Room Event: + +- Regular Events: contain useful content for the user (message, image, etc.), but are not necessarily displayed as this in the timeline (reaction, message + edition, call signaling). +- State Events: contain the state of the Room (name, topic, etc.). They have a non null value for the key `state_key`. + +Also all the Room Member details are in State Events: one State Event per member. In this case, the `state_key` is the matrixId (= userId). + +Important Fields of an Event: + +- `event_id`: unique across the Matrix universe; +- `room_id`: the room the Event belongs to; +- `type`: describe what the Event contain, especially in the `content` section, and how the SDK should handle this Event; +- `content`: dynamic Event data; depends on the `type`. + +So we have a triple `event_id`, `type`, `state_key` which uniquely defines an Event. + +#### Sync + +This is managed by the Rust SDK. + +### Rust SDK + +The Rust SDK is hosted here: https://github.com/matrix-org/matrix-rust-sdk. + +This repository contains an implementation of a Matrix client-server library written in Rust. + +With some bindings we can embed this sdk inside other environments, like Swift or Kotlin, with the help of [Uniffi](https://github.com/mozilla/uniffi-rs). +From these kotlin bindings we can generate native libs (.so files) and kotlin classes/interfaces. + +#### Matrix Rust Component Kotlin + +To use these bindings in an android project, we need to wrap this up into an android library (as the form of an .aar file). +This is the goal of https://github.com/matrix-org/matrix-rust-components-kotlin. +This repository is used for distributing kotlin releases of the Matrix Rust SDK. +It'll provide the corresponding aar and also publish them on maven. + +Most of the time you want to use the releases made on maven with gradle: + +```groovy +implementation("org.matrix.rustcomponents:sdk-android:latest-version") +``` + +You can also have access to the aars through the [release](https://github.com/matrix-org/matrix-rust-components-kotlin/releases) page. + +#### Build the SDK locally + +If you need to locally build the sdk-android you can use +the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script. + +For this, you first need to ensure to setup : + +- rust environment (check https://rust-lang.github.io/rustup/ if needed) +- cargo-ndk < 2.12.0 +```shell +cargo install cargo-ndk --version 2.11.0 +``` +- android targets +```shell +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android +``` +- checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories +```shell +git clone git@github.com:matrix-org/matrix-rust-sdk.git +git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git +``` + +Then you can launch the build script from the matrix-rust-components-kotlin repository with the following params: + +- `-p` Local path to the rust-sdk repository +- `-o` Optional output path with the expected name of the aar file. By default the aar will be located in the corresponding build/outputs/aar directory. +- `-r` Flag to build in release mode +- `-m` Option to select the gradle module to build. Default is sdk. +- `-t` Option to to select an android target to build against. Default will build for all targets. + +So for example to build the sdk against aarch64-linux-android target and copy the generated aar to ElementX project: + +```shell +./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar +``` + +Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`: + +```groovy +dependencies { + api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line. + //implementation(libs.matrix.sdk) // <- use the released version. Comment this line. +} +``` + +You are good to test your local rust development now! + +### The Android project + +The project should compile out of the box. + +This Android project is a multi modules project. + +- `app` module is the Android application module. Other modules are libraries; +- `features` modules contain some UI and can be seen as screen or flow of screens of the application; +- `libraries` modules contain classes that can be useful for other modules to work. + +A few details about some modules: + +- `libraries-core` module contains utility classes; +- `libraries-designsystem` module contains Composables which can be used across the app (theme, etc.); +- `libraries-elementresources` module contains resource from Element Android (mainly strings); +- `libraries-matrix` module contains wrappers around the Matrix Rust SDK. + +Most of the time a feature module should not know anything about other feature module. +The navigation glue is currently done in the `app` module. + +Here is the current simplified module dependency graph: + +<!-- Note: a full graph can be generated using `./tools/docs/generateModuleGraph.sh`. --> +<!-- Note: doc can be found at https://mermaid.js.org/syntax/flowchart.html#graph --> +```mermaid +flowchart TD + subgraph Application + app([:app])--implementation-->appnav([:appnav]) + end + subgraph Features + featureapi([:features:*:api]) + featureimpl([:features:*:impl]) + end + subgraph Libraries + subgraph Matrix + matrixapi([:matrix:api]) + matriximpl([:matrix:impl]) + end + libraryarch([:libraries:architecture]) + libraryapi([:libraries:*:api]) + libraryimpl([:libraries:*:impl]) + end + subgraph Matrix RustSdk + RustSdk([Rust Sdk]) + end + + app--implementation-->featureimpl + app--implementation-->libraryimpl + appnav--implementation-->featureapi + appnav--implementation-->libraryarch + featureimpl--api-->featureapi + featureimpl--implementation-->matrixapi + featureimpl--implementation-->libraryapi + featureimpl--implementation-->libraryarch + matriximpl--implementation-->matrixapi + matrixapi--api-->RustSdk + matriximpl--api-->RustSdk + featureapi--implementation-->libraryarch + libraryimpl--api-->libraryapi +``` + +### Application + +This Android project mainly handle the application layer of the whole software. The communication with the Matrix server, as well as the local storage, the +cryptography (encryption and decryption of Event, key management, etc.) is managed by the Rust SDK. + +The application is responsible to store the session credentials though. + +#### Jetpack Compose + +Compose is essentially two libraries : Compose Compiler and Compose UI. The compiler (and his runtime) is actually not specific to UI at all and offer powerful +state management APIs. See https://jakewharton.com/a-jetpack-compose-by-any-other-name/ + +Some useful links: + +- https://developer.android.com/jetpack/compose/mental-model +- https://developer.android.com/jetpack/compose/libraries +- https://developer.android.com/jetpack/compose/modifiers-list +- https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#api-guidelines-for-jetpack-compose + +About Preview + +- https://alexzh.com/jetpack-compose-preview/ + +#### Global architecture + +Main libraries and frameworks used in this application: + +- Navigation state with [Appyx](https://bumble-tech.github.io/appyx/). Please + watch [this video](https://www.droidcon.com/2022/11/15/model-driven-navigation-with-appyx-from-zero-to-hero/) to learn more about Appyx! +- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil). Please + watch [this video](https://www.droidcon.com/2022/06/28/dagger-anvil-learning-to-love-dependency-injection/) to learn more about Anvil! +- Reactive State management with Compose runtime and [Molecule](https://github.com/cashapp/molecule) + +Some patterns are inspired by [Circuit](https://slackhq.github.io/circuit/) + +Here are the main points: + +1. `Presenter` and `View` does not communicate with each other directly, but through `State` and `Event` +2. Views are compose first +3. Presenters are also compose first, and have a single `present(): State` method. It's using the power of compose-runtime/compiler. +4. The point of connection between a `View` and a `Presenter` is a `Node`. +5. A `Node` is also responsible for managing Dagger components if any. +6. A `ParentNode` has some children `Node` and only know about them. +7. This is a single activity full compose application. The `MainActivity` is responsible for holding and configuring the `RootNode`. +8. There is no more needs for Android Architecture Component ViewModel as configuration change should be handled by Composable if needed. + +#### Template and naming + +This documentation provides you with the steps to install and use the AS plugin for generating modules in your project. +The plugin and templates will help you quickly create new features with a standardized structure. + +A. Installation + +Follow these steps to install and configure the plugin and templates: + +1. Install the AS plugin for generating modules : + [Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template) +2. Import file templates in AS : + - Navigate to File/Manage IDE Settings/Import Settings + - Pick the `tools/templates/file_templates.zip` files + - Click on OK +3. Configure generate-module-from-template plugin : + - Navigate to AS/Settings/Tools/Module Template Settings + - Click on + / Import From File + - Pick the `tools/templates/FeatureModule.json` + +Everything should be ready to use. + +B. Usage + +Example for a new feature called RoomDetails: + +1. Right-click on the features package and click on Create Module from Template +2. Fill the 2 text fields like so: + - MODULE_NAME = roomdetails + - FEATURE_NAME = RoomDetails +3. Click on Next +4. Verify that the structure looks ok and click on Finish +5. The modules api/impl should be created under `features/roomdetails` directory. +6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle). +7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`. + To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`. + Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package. + + +Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a +suffix `Presenter`,states MUST have a suffix `State`, etc. Also we want to have a common naming along all the modules. + +### Push + +**Note** Firebase Push is not yet implemented on the project. + +Please see the dedicated [documentation](notifications.md) for more details. + +This is the classical scenario: + +- App receives a Push. Note: Push is ignored if app is in foreground; +- App asks the SDK to load Event data (fastlane mode). We have a change to get the data faster and display the notification faster; +- App asks the SDK to perform a sync request. + +### Dependencies management + +We are using [Gradle version catalog](https://docs.gradle.org/current/userguide/platforms.html#sub:central-declaration-of-dependencies) on this project. + +All the dependencies (including android artifact, gradle plugin, etc.) should be declared in [../gradle/libs.versions.toml](libs.versions.toml) file. +Some dependency, mainly because they are not shared can be declared in `build.gradle.kts` files. + +[Renovate](https://github.com/apps/renovate) is set up on the project. This tool will automatically create Pull Request to upgrade our dependencies one by one. A [dependency dashboard issue](https://github.com/vector-im/element-x-android/issues/150) is maintained by the tool and allow to perform some actions. + +### Test + +We have 3 tests frameworks in place, and this should be sufficient to guarantee a good code coverage and limit regressions hopefully: + +- Maestro to test the global usage of the application. See the related [documentation](../.maestro/README.md). +- Combination of [Showkase](https://github.com/airbnb/Showkase) and [Paparazzi](https://github.com/cashapp/paparazzi), to test UI pixel perfect. To add test, + just add `@Preview` for the composable you are adding. See the related [documentation](screenshot_testing.md) and see in the template the + file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide + different states. See for instance the + file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt) + - Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the + class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt). + +**Note** For now we want to avoid using class mocking (with library such as *mockk*), because this should be not necessary. We prefer to create Fake +implementation of our interfaces. Mocking can be used to mock Android framework classes though, such as `Bitmap` for instance. + +### Code coverage + +[kover](https://github.com/Kotlin/kotlinx-kover) is used to compute code coverage. Only have unit tests can produce code coverage result. Running Maestro does +not participate to the code coverage results. + +Kover configuration is defined in the main [build.gradle.kts](../build.gradle.kts) file. + +To compute the code coverage, run: + +```bash +./gradlew koverMergedReport +``` + +and open the Html report: [../build/reports/kover/merged/html/index.html](../build/reports/kover/merged/html/index.html) + +To ensure that the code coverage threshold are OK, you can run + +```bash +./gradlew koverMergedVerify +``` + +Note that the CI performs this check on every pull requests. + +Also, if the rule `Global minimum code coverage.` is in error because code coverage is `> maxValue`, `minValue` and `maxValue` can be updated for this rule in +the file [build.gradle.kts](../build.gradle.kts) (you will see further instructions there). + +### Other points + +#### Logging + +**Important warning: ** NEVER log private user data, or use the flag `LOG_PRIVATE_DATA`. Be very careful when logging `data class`, all the content will be +output! + +[Timber](https://github.com/JakeWharton/timber) is used to log data to logcat. We do not use directly the `Log` class. If possible please use a tag, as per + +````kotlin +Timber.tag(loggerTag.value).d("my log") +```` + +because automatic tag (= class name) will not be available on the release version. + +Also generally it is recommended to provide the `Throwable` to the Timber log functions. + +Last point, note that `Timber.v` function may have no effect on some devices. Prefer using `Timber.d` and up. + + +#### Translations + +Translations are handled through localazy. See [the dedicated README.md file](../tools/localazy/README.md) for information on how +to configure new modules etc. + +#### Rageshake + +Rageshake is a feature to send bug report directly from the application. Just shake your phone and you will be prompted to send a bug report. + +Bug reports can contain: + +- a screenshot of the current application state +- the application logs from up to 15 application starts +- the logcat logs + +The data will be sent to an internal server, which is not publicly accessible. A GitHub issue will also be created to a private GitHub repository. + +Rageshake can be very useful to get logs from a release version of the application. + +### Tips + +- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!) +- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO + Not supported yet!) +- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!) +- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those + screens, it will be possible to toggle some feature flags; (TODO Not supported yet!) +- Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on + the screen can also help to find the running code in the codebase. +- When this is possible, prefer using `sealed interface` instead of `sealed class`; +- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI + will detect this String and will warn the user about it. (TODO Not supported yet!) +- Very occasionally the gradle cache misbehaves and causes problems with Dagger. Try building with `--no-build-cache` if Dagger isn't behaving how you expect. + +## Happy coding! + +The team is here to support you, feel free to ask anything to other developers. + +Also please feel free to update this documentation, if incomplete/wrong/obsolete/etc. + +**Thanks!** diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000000..b3f592c227 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,11 @@ +# Analytics in Element + +<!--- TOC --> + +* [TODO](#todo) + +<!--- END --> + +## TODO + +There is no analytics in the project yet. diff --git a/docs/danger.md b/docs/danger.md new file mode 100644 index 0000000000..e6fa74dec2 --- /dev/null +++ b/docs/danger.md @@ -0,0 +1,106 @@ +## Danger + +<!--- TOC --> + +* [What does danger checks](#what-does-danger-checks) + * [PR check](#pr-check) + * [Quality check](#quality-check) +* [Setup](#setup) +* [Run danger locally](#run-danger-locally) +* [Danger user](#danger-user) +* [Useful links](#useful-links) + +<!--- END --> + +## What does danger checks + +### PR check + +See the [dangerfile](../tools/danger/dangerfile.js). If you add rules in the dangerfile, please update the list below! + +Here are the checks that Danger does so far: + +- PR description is not empty +- Big PR got a warning to recommend to split +- PR contains a file for towncrier and extension is checked +- PR does not modify frozen classes +- PR contains a Sign-Off, with exception for Element employee contributors +- PR with change on layout should include screenshot in the description (TODO Not supported yet!) +- PR which adds png file warn about the usage of vector drawables +- non draft PR should have a reviewer +- files containing translations are not modified by developers + +### Quality check + +After all the checks that generate checkstyle XML report, such as Ktlint, lint, or Detekt, Danger is run with this [dangerfile](../tools/danger/dangerfile-lint.js), in order to post comments to the PR with the detected error and warnings. + +To run locally, you will have to install the plugin `danger-plugin-lint-report` using: + +```shell +yarn add danger-plugin-lint-report --dev +``` + +## Setup + +This operation should not be necessary, since Danger is already setup for the project. + +To setup danger to the project, run: + +```shell +bundle exec danger init +``` + +## Run danger locally + +When modifying the [dangerfile](../tools/danger/dangerfile.js), you can check it by running Danger locally. + +To run danger locally, install it and run: + +```shell +bundle exec danger pr <PR_URL> --dangerfile=./tools/danger/dangerfile.js +``` + +For instance: + +```shell +bundle exec danger pr https://github.com/vector-im/element-android/pull/6637 --dangerfile=./tools/danger/dangerfile.js +``` + +We may need to create a GitHub token to have less API rate limiting, and then set the env var: + +```shell +export DANGER_GITHUB_API_TOKEN='YOUR_TOKEN' +``` + +Swift and Kotlin (just in case) + +```shell +bundle exec danger-swift pr <PR_URL> --dangerfile=./tools/danger/dangerfile.js +bundle exec danger-kotlin pr <PR_URL> --dangerfile=./tools/danger/dangerfile.js +``` + +## Danger user + +To let Danger check all the PRs, including PRs form forks, a GitHub account have been created: +- login: ElementBot +- password: Stored on Passbolt +- GitHub token: A token with limited access has been created and added to the repository https://github.com/vector-im/element-android as secret DANGER_GITHUB_API_TOKEN. This token is not saved anywhere else. In case of problem, just delete it and create a new one, then update the secret. + +PRs from forks do not always have access to the secret `secrets.DANGER_GITHUB_API_TOKEN`, so `secrets.GITHUB_TOKEN` is also provided to the job environment. If `secrets.DANGER_GITHUB_API_TOKEN` is available, it will be used, so user `ElementBot` will comment the PR. Else `secrets.GITHUB_TOKEN` will be used, and bot `github-actions` will comment the PR. + +## Useful links + +- https://danger.systems/ +- https://danger.systems/js/ +- https://danger.systems/js/guides/getting_started.html +- https://danger.systems/js/reference.html +- https://github.com/danger/awesome-danger + +Some danger files to get inspired from + +- https://github.com/artsy/emission/blob/master/dangerfile.ts +- https://github.com/facebook/react-native/blob/master/bots/dangerfile.js +- https://github.com/apollographql/apollo-client/blob/master/config/dangerfile.ts +- https://github.com/styleguidist/react-styleguidist/blob/master/dangerfile.js +- https://github.com/storybooks/storybook/blob/master/dangerfile.js +- https://github.com/ReactiveX/rxjs/blob/master/dangerfile.js diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000000..58723eb28d --- /dev/null +++ b/docs/design.md @@ -0,0 +1,161 @@ +# Element Android design + +<!--- TOC --> + +* [Introduction](#introduction) +* [How to import from Figma to the Element Android project](#how-to-import-from-figma-to-the-element-android-project) + * [Colors](#colors) + * [Text](#text) + * [Dimension, position and margin](#dimension-position-and-margin) + * [Icons](#icons) + * [Custom icons](#custom-icons) + * [Export drawable from Figma](#export-drawable-from-figma) + * [Import in Android Studio](#import-in-android-studio) + * [Images](#images) +* [Figma links](#figma-links) + * [Compound](#compound) + * [Login](#login) + * [Login v2](#login-v2) + * [Room list](#room-list) + * [Timeline](#timeline) + * [Voice message](#voice-message) + * [Room settings](#room-settings) + * [VoIP](#voip) + * [Presence](#presence) + * [Spaces](#spaces) + * [List to be continued...](#list-to-be-continued) + +<!--- END --> + +**TODO This documentation is a bit outdated and must be updated when we will set up the design components.** + +## Introduction + +Design at element.io is done using Figma - https://www.figma.com +You will find guidance to build using interface on the [Compound documentation – Element's design system](https://compound.element.io) + +## How to import from Figma to the Element Android project + +Integration should be done using the Android development best practice, and should follow the existing convention in the code. + +### Colors + +Element Android already contains all the colors which can be used by the designer, in the module `ui-style`. +Some of them depend on the theme, so ensure to use theme attributes and not colors directly. + +A comprehensive [color definition documentation](https://compound.element.io/?path=/docs/tokens-color-palettes--docs) is available in Compound. + + +### Text + + - click on a text on Figma + - on the right panel, information about the style and colors are displayed + - in Element Android, text style are already defined, generally you should not create new style + - apply the style and the color to the layout + +### Dimension, position and margin + + - click on an item on Figma + - dimensions of the item will be displayed. + - move the mouse to other items to get relative positioning, margin, etc. + +### Icons + +Most icons should be available as part of the [Compound icon library](https://compound.element.io/?path=/docs/tokens-icons--docs) + +All drawable are auto-generated as part of the design tokens library. You can find +all assets in [`vector-im/compound-design-tokens#assets/android`](https://github.com/vector-im/compound-design-tokens/tree/develop/assets/android) + +If you are missing an icon, follow to [contribution guidelines for icons](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons?type=design&node-id=178-3119&t=j2uSJD9xPXJn5aRM-0) + +#### Custom icons + +##### Export drawable from Figma + + - click on the element to export + - ensure that the correct layer is selected. Sometimes the parent layer has to be selected on the left panel + - on the right panel, click on "export" + - select SVG + - you can check the preview of what will be exported + - click on "export" and save the file locally + - unzip the file if necessary + +It's also possible for any icon to go to the main component by right-clicking on the icon. + +##### Import in Android Studio + + - right click on the drawable folder where the drawable will be created + - click on "New"/"Vector Asset" + - select the exported file + - update the filename if necessary + - click on "Next" and click on "Finish" + - open the created vector drawable + - optionally update the color(s) to "#FF0000" (red) to ensure that the drawable is correctly tinted at runtime. + +### Images + +Android 4.3 (18+) fully supports the WebP image format which can often provide smaller image sizes without drastically impacting image quality (depending on the output encoding quality). +When importing non vector images, WebP is the preferred format. + +Images can be converted to the WebP within Android Studio by + - right clicking the image file within the project file explorer + - select `Convert to WebP` + +https://developer.android.com/studio/write/convert-webp + +## Figma links + +Figma links can be included in the layout, for future reference, but it is also OK to add a paragraph below here, to centralize the information + +Main entry point: https://www.figma.com/files/project/5612863/Element?fuid=779371459522484071 + +Note: all the Figma links are not publicly available. + +### Compound + +Compound is Element's design system where you'll find styles and documentation +regarding user interfaces. + +- Documentation: [https://compound.element.io](https://compound.element.io) +- [Compound Android – Figma document](https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components) +- [Compound Styles - Figma document](https://www.figma.com/file/PpKepmHKGikp33Ql7iivbn/Compound-Styles?type=design) +- [Compound Icons - Figma document](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons) + +### Login + +TBD + +#### Login v2 + +https://www.figma.com/file/xdV4PuI3DlzA1EiBvbrggz/Login-Flow-v2 + +### Room list + +TBD + +### Timeline + +https://www.figma.com/file/x1HYYLYMmbYnhfoz2c2nGD/%5BRiotX%5D-Misc?node-id=0%3A1 + +### Voice message + +https://www.figma.com/file/uaWc62Ux2DkZC4OGtAGcNc/Voice-Messages?node-id=473%3A12 + +### Room settings + +TBD + +### VoIP + +https://www.figma.com/file/V6m2z0oAtUV1l8MdyIrAep/VoIP?node-id=4254%3A25767 + +### Presence + +https://www.figma.com/file/qmvEskET5JWva8jZJ4jX8o/Presence---User-Status?node-id=114%3A9174 +(Option B is chosen) + +### Spaces + +https://www.figma.com/file/m7L63aGPW7iHnIYStfdxCe/Spaces?node-id=192%3A30161 + +### List to be continued... diff --git a/docs/images/module_graph.png b/docs/images/module_graph.png new file mode 100644 index 0000000000..3be0256646 Binary files /dev/null and b/docs/images/module_graph.png differ diff --git a/docs/images/screen1.png b/docs/images/screen1.png new file mode 100644 index 0000000000..9f9d7747ff Binary files /dev/null and b/docs/images/screen1.png differ diff --git a/docs/images/screen2.png b/docs/images/screen2.png new file mode 100644 index 0000000000..a5733003d6 Binary files /dev/null and b/docs/images/screen2.png differ diff --git a/docs/images/screen3.png b/docs/images/screen3.png new file mode 100644 index 0000000000..3edb49d086 Binary files /dev/null and b/docs/images/screen3.png differ diff --git a/docs/images/screen4.png b/docs/images/screen4.png new file mode 100644 index 0000000000..53da801a1b Binary files /dev/null and b/docs/images/screen4.png differ diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md new file mode 100644 index 0000000000..634ee905ab --- /dev/null +++ b/docs/installing_from_ci.md @@ -0,0 +1,49 @@ +## Installing from CI + +<!--- TOC --> + + * [Installing from GitHub](#installing-from-github) + * [Create a GitHub token](#create-a-github-token) + * [Provide artifact URL](#provide-artifact-url) + * [Next steps](#next-steps) + * [Future improvement](#future-improvement) + +<!--- END --> + +Installing APK build by the CI is possible + +### Installing from GitHub + +TODO Import the script from Element Android and make it work, then update this documentation. + +To install an APK built by a GitHub action, run the script `./tools/install/installFromGitHub.sh`. You will need to pass a GitHub token to do so. + +#### Create a GitHub token + +You can create a GitHub token going to your Github account, at this page: [https://github.com/settings/tokens](https://github.com/settings/tokens). + +You need to create a token (classic) with the scope `repo/public_repo`. So just check the corresponding checkbox. +Validity can be long since the scope of this token is limited. You will still be able to delete the token and generate a new one. +Click on Generate token and save the token locally. + +### Provide artifact URL + +The script will ask for an artifact URL. You can get this artifact URL by following these steps: + +- open the pull request +- in the check at the bottom, click on `APK Build / Build debug APKs` +- click on `Summary` +- scroll to the bottom of the page +- copy the link `vector-Fdroid-debug` if you want the F-Droid variant or `vector-Gplay-debug` if you want the Gplay variant. + +The copied link can be provided to the script. + +### Next steps + +The script will download the artifact, unzip it and install the correct version (regarding arch) on your device. + +Files will be added to the folder `./tmp/DebugApks`. Feel free to cleanup this folder from time to time, the script will not delete files. + +### Future improvement + +The script could ask the user for a Pull Request number and Gplay/Fdroid choice like it was done with Buildkite script. Using GitHub API may be possible to do that. diff --git a/docs/integration_tests.md b/docs/integration_tests.md new file mode 100644 index 0000000000..b5a830e7ff --- /dev/null +++ b/docs/integration_tests.md @@ -0,0 +1,131 @@ +# Integration tests + +<!--- TOC --> + +* [Pre requirements](#pre-requirements) +* [Install and run Synapse](#install-and-run-synapse) +* [Run the test](#run-the-test) +* [Stop Synapse](#stop-synapse) +* [Troubleshoot](#troubleshoot) + * [Android Emulator does cannot reach the homeserver](#android-emulator-does-cannot-reach-the-homeserver) + * [Tests partially run but some fail with "Unable to contact localhost:8080"](#tests-partially-run-but-some-fail-with-"unable-to-contact-localhost:8080") + * [virtualenv command fails](#virtualenv-command-fails) + +<!--- END --> + +Integration tests are useful to ensure that the code works well for any use cases. + +They can also be used as sample on how to use the Matrix SDK. + +In a ideal world, every API of the SDK should be covered by integration tests. For the moment, we have test mainly for the Crypto part, which is the tricky part. But it covers quite a lot of features: accounts creation, login to existing account, send encrypted messages, keys backup, verification, etc. + +The Matrix SDK is able to open multiple sessions, for the same user, of for different users. This way we can test communication between several sessions on a single device. + +## Pre requirements + +Integration tests need a homeserver running on localhost. + +The documentation describes what we do to have one, using [Synapse](https://github.com/matrix-org/synapse/), which is the Matrix reference homeserver. + +## Install and run Synapse + +Steps: + +- Install virtualenv + +```bash +python3 -m pip install virtualenv +``` + +- Clone Synapse repository + +```bash +git clone -b develop https://github.com/matrix-org/synapse.git +``` +or +```bash +git clone -b develop git@github.com:matrix-org/synapse.git +``` + +You should have the develop branch cloned by default. + +- Run synapse, from the Synapse folder you just cloned + +```bash +virtualenv -p python3 env +source env/bin/activate +pip install -e . +demo/start.sh --no-rate-limit + +``` + +Alternatively, to install the latest Synapse release package (and not a cloned branch) you can run the following instead of `git clone` and `pip install -e .`: + +```bash +pip install matrix-synapse +``` + +On your first run, you will want to stop the demo and edit the config to correct the `public_baseurl` to http://10.0.2.2:8080 and restart the server. + +You should now have 3 running federated Synapse instances 🎉, at http://127.0.0.1:8080/, http://127.0.0.1:8081/ and http://127.0.0.1:8082/, which should display a "It Works! Synapse is running" message. + +## Run the test + +It's recommended to run tests using an Android Emulator and not a real device. First reason for that is that the tests will use http://10.0.2.2:8080 to connect to Synapse, which run locally on your machine. + +You can run all the tests in the `androidTest` folders. + +It can be done using this command: + +```bash +./gradlew vector:connectedAndroidTest matrix-sdk-android:connectedAndroidTest +``` + +## Stop Synapse + +To stop Synapse, you can run the following commands: + +```bash +./demo/stop.sh +``` + +And you can deactivate the virtualenv: + +```bash +deactivate +``` + +## Troubleshoot + +You'll need python3 to be able to run synapse + +### Android Emulator does cannot reach the homeserver + +Try on the Emulator browser to open "http://10.0.2.2:8080". You should see the "Synapse is running" message. + +### Tests partially run but some fail with "Unable to contact localhost:8080" + +This is because the `public_baseurl` of synapse is not consistent with the endpoint that the tests are connecting to. + +Ensure you have the following configuration in `demo/etc/8080.config`. + +``` +public_baseurl: http://10.0.2.2:8080/ +``` + +After changing this you will need to restart synapse using `demo/stop.sh` and `demo/start.sh` to load the new configuration. + +### virtualenv command fails + +You can try using +```bash +python3 -m venv env +``` +or +```bash +python3 -m virtualenv env +``` +instead of +```bash +virtualenv -p python3 env +``` diff --git a/docs/maps.md b/docs/maps.md new file mode 100644 index 0000000000..cc00905986 --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,42 @@ +# Use of maps + +<!--- TOC --> + +* [Overview](#overview) +* [Local development with MapTiler](#local-development-with-maptiler) +* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler) +* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles) + +<!--- END --> + +## Overview + +Element Android uses [MapTiler](https://www.maptiler.com/) to provide map +imagery where required. MapTiler requires an API key, which we bake in to +the app at release time. + +## Local development with MapTiler + +If you're developing the application and want maps to render properly you can +sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/). + +Place your API key in `local.properties` with the key +`services.maptiler.apikey`, e.g.: + +```properties +services.maptiler.apikey=abCd3fGhijK1mN0pQr5t +``` + +## Making releasable builds with MapTiler + +To insert the MapTiler API key when building an APK, set the +`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build +environment. + +## Using other map sources or MapTiler styles + +If you wish to use an alternative map provider, or custom MapTiler styles, +you can customise the functions in +`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`. +We've kept this file small and self contained to minimise the chances of merge +collisions in forks. diff --git a/docs/nightly_build.md b/docs/nightly_build.md new file mode 100644 index 0000000000..9abd59a67b --- /dev/null +++ b/docs/nightly_build.md @@ -0,0 +1,52 @@ +# Nightly builds + +<!--- TOC --> + +* [Configuration](#configuration) +* [How to register to get nightly build](#how-to-register-to-get-nightly-build) +* [Build nightly manually](#build-nightly-manually) + +<!--- END --> + +## Configuration + +The nightly build will contain what's on develop, in release mode, for the main variant. It is signed using a dedicated signature, and has a dedicated appId (`io.element.android.x.nightly`), so it can be installed along with the production version of ElementX Android. The only other difference compared to ElementX Android is a different app name. We do not want to change the app name since it will also affect some strings in the app, and we do want to do that. (TODO today, the app name is changed.) + +Nightly builds are built and released to Firebase every days, and automatically. + +This is recommended to exclusively use this app, with your main account, instead of ElementX Android, and fallback to ElementX Android just in case of regression, to discover as soon as possible any regression, and report it to the team. To avoid double notification, you may want to disable the notification from the Element Android production version. Just open Element Android, navigate to `Settings/Notifications` and uncheck `Enable notifications for this session` (TODO Not supported yet). + +*Note:* Due to a limitation of Firebase, the nightly build is the universal build, which means that the size of the APK is a bit bigger, but this should not have any other side effect. + +## How to register to get nightly build + +Click on this link and follow the instruction: [https://appdistribution.firebase.dev/i/7de2dbc61e7fb2a6](https://appdistribution.firebase.dev/i/7de2dbc61e7fb2a6) + +## Build nightly manually + +Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`: + +``` +signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT +signing.element.nightly.keyId=VALUE_FROM_PASSBOLT +signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT +``` + +You will also need to add the environment variable `FIREBASE_TOKEN`: + +```sh +export FIREBASE_TOKEN=VALUE_FROM_PASSBOLT +``` + +Then you can run the following commands (which are also used in the file for [the GitHub action](../.github/workflows/nightly.yml)): + +```sh +git checkout develop +mv towncrier.toml towncrier.toml.bak +sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml +rm towncrier.toml.bak +yes n | towncrier build --version nightly +./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES +``` + +Then you can reset the change on the codebase. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000000..612b8785b8 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,284 @@ +This document aims to describe how Element android displays notifications to the end user. It also clarifies notifications and background settings in the app. + +# Table of Contents + +<!--- TOC --> + +* [Prerequisites Knowledge](#prerequisites-knowledge) + * [How does a matrix client get a message from a homeserver?](#how-does-a-matrix-client-get-a-message-from-a-homeserver?) + * [How does a mobile app receives push notification](#how-does-a-mobile-app-receives-push-notification) + * [Push VS Notification](#push-vs-notification) + * [Push in the matrix federated world](#push-in-the-matrix-federated-world) + * [How does the homeserver know when to notify a client?](#how-does-the-homeserver-know-when-to-notify-a-client?) + * [Push vs privacy, and mitigation](#push-vs-privacy-and-mitigation) + * [Background processing limitations](#background-processing-limitations) +* [Element Notification implementations](#element-notification-implementations) + * [Requirements](#requirements) + * [Foreground sync mode (Gplay and F-Droid)](#foreground-sync-mode-gplay-and-f-droid) + * [Push (FCM) received in background](#push-fcm-received-in-background) + * [FCM Fallback mode](#fcm-fallback-mode) + * [F-Droid background Mode](#f-droid-background-mode) +* [Application Settings](#application-settings) + +<!--- END --> + + +First let's start with some prerequisite knowledge + +## Prerequisites Knowledge + +### How does a matrix client get a message from a homeserver? + +In order to get messages from a homeserver, a matrix client need to perform a ``sync`` operation. + +`To read events, the intended flow of operation is for clients to first call the /sync API without a since parameter. This returns the most recent message events for each room, as well as the state of the room at the start of the returned timeline. ` + +The client need to call the `sync` API periodically in order to get incremental updates of the server state (new messages). +This mechanism is known as **HTTP long Polling**. + +Using the **HTTP Long Polling** mechanism a client polls a server requesting new information. +The server *holds the request open until new data is available*. +Once available, the server responds and sends the new information. +When the client receives the new information, it immediately sends another request, and the operation is repeated. +This effectively emulates a server push feature. + +The HTTP long Polling can be fine tuned in the **SDK** using two parameters: +* timeout (Sync request timeout) +* delay (Delay between each sync) + +**timeout** is a server parameter, defined by: +``` +The maximum time to wait, in milliseconds, before returning this request.` +If no events (or other data) become available before this time elapses, the server will return a response with empty fields. +By default, this is 0, so the server will return immediately even if the response is empty. +``` + +**delay** is a client preference. When the server responds to a sync request, the client waits for `delay`before calling a new sync. + +When the Element Android app is open (i.e in foreground state), the default timeout is 30 seconds, and delay is 0. + +### How does a mobile app receives push notification + +Push notification is used as a way to wake up a mobile application when some important information is available and should be processed. + +Typically in order to get push notification, an application relies on a **Push Notification Service** or **Push Provider**. + +For example iOS uses APNS (Apple Push Notification Service). +Most of android devices relies on Google's Firebase Cloud Messaging (FCM). + > FCM has replaced Google Cloud Messaging (GCM - deprecated April 10 2018) + +FCM will only work on android devices that have Google plays services installed +(In simple terms, Google Play Services is a background service that runs on Android, which in turn helps in integrating Google’s advanced functionalities to other applications) + +De-Googlified devices need to rely on something else in order to stay up to date with a server. +There some cases when devices with google services cannot use FCM (network infrastructure limitations -firewalls-, + privacy and or independence requirement, source code licence) + +### Push VS Notification + +This need some disambiguation, because it is the source of common confusion: + + +*The fact that you see a notification on your screen does not mean that you have successfully configured your PUSH platform.* + + Technically there is a difference between a push and a notification. A notification is what you see on screen and/or in the notification Menu/Drawer (in the top bar of the phone). + + Notifications are not always triggered by a push (One can display a notification locally triggered by an alarm) + + +### Push in the matrix federated world + +In order to send a push to a mobile, App developers need to have a server that will use the FCM APIs, and these APIs requires authentication! +This server is called a **Push Gateway** in the matrix world + +That means that Element Android, a matrix client created by New Vector, is using a **Push Gateway** with the needed credentials (FCM API secret Key) in order to send push to the New Vector client. + +If you create your own matrix client, you will also need to deploy an instance of a **Push Gateway** with the credentials needed to use FCM for your app. + +On registration, a matrix client must tell its homeserver what Push Gateway to use. + +See [Sygnal](https://github.com/matrix-org/sygnal/) for a reference implementation. +``` + + +--------------------+ +-------------------+ + Matrix HTTP | | | | + Notification Protocol | App Developer | | Device Vendor | + | | | | + +-------------------+ | +----------------+ | | +---------------+ | + | | | | | | | | | | + | Matrix homeserver +-----> Push Gateway +------> Push Provider | | + | | | | | | | | | | + +-^-----------------+ | +----------------+ | | +----+----------+ | + | | | | | | + Matrix | | | | | | +Client/Server API + | | | | | + | | +--------------------+ +-------------------+ + | +--+-+ | + | | <-------------------------------------------+ + +---+ | + | | Provider Push Protocol + +----+ + + Mobile Device or Client +``` + +Recommended reading: +* https://thomask.sdf.org/blog/2016/12/11/riots-magical-push-notifications-in-ios.html +* https://matrix.org/docs/spec/client_server/r0.4.0.html#id128 + + +### How does the homeserver know when to notify a client? + +This is defined by [**push rules**](https://matrix.org/docs/spec/client_server/r0.4.0.html#push-rules-). + +`A push rule is a single rule that states under what conditions an event should be passed onto a push gateway and how the notification should be presented (sound / importance).` + +A homeserver can be configured with default rules (for Direct messages, group messages, mentions, etc.. ). + +There are different kind of push rules, it can be per room (each new message on this room should be notified), it can also define a pattern that a message should match (when you are mentioned, or key word based). + +Notifications have 2 'levels' (`highlighted = true/false sound = default/custom`). In Element these notifications level are reflected as Noisy/Silent. + +**What about encrypted messages?** + +Of course, content patterns matching cannot be used for encrypted messages server side (as the content is encrypted). + +That is why clients are able to **process the push rules client side** to decide what kind of notification should be presented for a given event. + +### Push vs privacy, and mitigation + +As seen previously, App developers don't directly send a push to the end user's device, they use a Push Provider as intermediary. So technically this intermediary is able to read the content of what is sent. + +App developers usually mitigate this by sending a `silent notification`, that is a notification with no identifiable data, or with an encrypted payload. When the push is received the app can then synchronise to it's server in order to generate a local notification. + + +### Background processing limitations + +A mobile applications process live in a managed word, meaning that its process can be limited (e.g no network access), stopped or killed at almost anytime by the Operating System. + +In order to improve the battery life of their devices some constructors started to implement mechanism to drastically limit background execution of applications (e.g MIUI/Xiaomi restrictions, Sony stamina mode). +Then starting android M, android has also put more focus on improving device performances, introducing several IDLE modes, App-Standby, Light Doze, Doze. + +In a nutshell, apps can't do much in background now. + +If the devices is not plugged and stays IDLE for a certain amount of time, radio (mobile connectivity) and CPU can/will be turned off. + +For an application like Element, where users can receive important information at anytime, the best option is to rely on a push system (Google's Firebase Message a.k.a FCM). FCM high priority push can wake up the device and whitelist an application to perform background task (for a limited but unspecified amount of time). + +Notice that this is still evolving, and in future versions application that has been 'background restricted' by users won't be able to wake up even when a high priority push is received. Also high priority notifications could be rate limited (not defined anywhere) + +It's getting a lot more complicated when you cannot rely on FCM (because: closed sources, network/firewall restrictions, privacy concerns). +The documentation on this subject is vague, and as per our experiments not always exact, also device's behaviour is fragmented. + +It is getting more and more complex to have reliable notifications when FCM is not used. + +## Element Notification implementations + +### Requirements + +Element Android must work with and without FCM. +* The Element android app published on F-Droid do not rely on FCM (all related dependencies are not present) +* The Element android app published on google play rely on FCM, with a fallback mode when FCM registration has failed (e.g outdated or missing Google Play Services) + +### Foreground sync mode (Gplay and F-Droid) + +When in foreground, Element performs sync continuously with a timeout value set to 10 seconds (see HttpPooling). + +As this mode does not need to live beyond the scope of the application, and as per Google recommendation, Element uses the internal app resources (Thread and Timers) to perform the syncs. + +This mode is turned on when the app enters foreground, and off when enters background. + +In background, and depending on whether push is available or not, Element will use different methods to perform the syncs (Workers / Alarms / Service) + +### Push (FCM) received in background + +In order to enable Push, Element must first get a push token from the firebase SDK, then register a pusher with this token on the homeserver. + +When a message should be notified to a user, the user's homeserver notifies the registered `push gateway` for Element, that is [sygnal](https://github.com/matrix-org/sygnal) _- The reference implementation for push gateways -_ hosted by matrix.org. + +This sygnal instance is configured with the required FCM API authentication token, and will then use the FCM API in order to notify the user's device running Element. + +``` +Homeserver ----> Sygnal (configured for Element) ----> FCM ----> Element +``` + +The push gateway is configured to only send `(eventId,roomId)` in the push payload (for better [privacy](#push-vs-privacy-and-mitigation)). + +Element needs then to synchronise with the user's homeserver, in order to resolve the event and create a notification. + +As per [Google recommendation](https://android-developers.googleblog.com/2018/09/notifying-your-users-with-fcm.html), Element will then use the WorkManager API in order to trigger a background sync. + +**Google recommendations:** +> We recommend using FCM messages in combination with the WorkManager 1 or JobScheduler API + +> Avoid background services. One common pitfall is using a background service to fetch data in the FCM message handler, since background service will be stopped by the system per recent changes to Google Play Policy + +``` +Homeserver ----> Sygnal ----> FCM ----> Element + (Sync) ----> Homeserver + <---- + Display notification +``` + +**Possible outcomes** + +Upon reception of the FCM push, Element will perform a sync call to the homeserver, during this process it is possible that: + * Happy path, the sync is performed, the message resolved and displayed in the notification drawer + * The notified message is not in the sync. Can happen if a lot of things did happen since the push (`gappy sync`) + * The sync generates additional notifications (e.g an encrypted message where the user is mentioned detected locally) + * The sync takes too long and the process is killed before completion, or network is not reliable and the sync fails. + +Element implements several strategies in these cases (TODO document) + +### FCM Fallback mode + +It is possible that Element is not able to get a FCM push token. +Common errors (among several others) that can cause that: +* Google Play Services is outdated +* Google Play Service fails in someways with FCM servers (infamous `SERVICE_NOT_AVAILABLE`) + +If Element is able to detect one of this cases, it will notifies it to the users and when possible help him fix it via a dedicated troubleshoot screen. + +Meanwhile, in order to offer a minimal service, and as per Google's recommendation for background activities, Element will launch periodic background sync in order to stays in sync with servers. + +The fallback mode is impacted by all the battery life saving mechanism implemented by android. Meaning that if the app is not used for a certain amount of time (`App-Standby`), or the device stays still and unplugged (`Light Doze`) , the sync will become less frequent. + +And if the device stays unplugged and still for too long (`Doze Mode`), no background sync will be perform at all (the system's `Ignore Battery Optimization option` has no effect on that). + + Also the time interval between sync is elastic, controlled by the system to group other apps background sync request and start radio/cpu only once for all. + +Usually in this mode, what happen is when you take back your phone in your hand, you suddenly receive notifications. + +The fallback mode is supposed to be a temporary state waiting for the user to fix issues for FCM, or for App Developers that has done a fork to correctly configure their FCM settings. + +### F-Droid background Mode + +The F-Droid Element flavor has no dependencies to FCM, therefore cannot relies on Push. + +Also Google's recommended background processing method cannot be applied. This is because all of these methods are affected by IDLE modes, and will result on the user not being notified at all when the app is in a Doze mode (only in maintenance windows that could happens only after hours). + +Only solution left is to use `AlarmManager`, that offers new API to allow launching some process even if the App is in IDLE modes. + +Notice that these alarms, due to their potential impact on battery life, can still be restricted by the system. Documentation says that they will not be triggered more than every minutes under normal system operation, and when in low power mode about every 15 mn. + +These restrictions can be relaxed by requiring the app to be white listed from battery optimization. + +F-Droid version will schedule alarms that will then trigger a Broadcast Receiver, that in turn will launch a Service (in the classic android way), and the reschedule an alarm for next time. + +Depending on the system status (or device make), it is still possible that the app is not given enough time to launch the service, or that the radio is still turned off thus preventing the sync to success (that's why Alarms are not recommended for network related tasks). + +That is why on Element F-Droid, the broadcast receiver will acquire a temporary WAKE_LOCK for several seconds (thus securing cpu/network), and launch the service in foreground. The service performs the sync. + +Note that foreground services require to put a notification informing the user that the app is doing something even if not launched). + +## Application Settings + +**Notifications > Enable notifications for this account** + +Configure Sygnal to send or not notifications to all user devices. + +**Notifications > Enable notifications for this device** + +Disable notifications locally. The push server will continue to send notifications to the device but this one will ignore them. + + diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 0000000000..5f9e70268d --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,47 @@ +This file contains some rough notes about Oidc implementation, with some examples of actual data. + +[ios implementation](https://github.com/vector-im/element-x-ios/compare/develop...doug/oidc-temp) + +Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi + +Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0 + +Server list: https://github.com/vector-im/oidc-playground + +Metadata iOS: (from https://github.com/vector-im/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28) + +clientName: InfoPlistReader.main.bundleDisplayName, +redirectUri: "io.element:/callback", +clientUri: "https://element.io", +tosUri: "https://element.io/user-terms-of-service", +policyUri: "https://element.io/privacy" + + +Android: +clientName = "Element", +redirectUri = "io.element:/callback", +clientUri = "https://element.io", +tosUri = "https://element.io/user-terms-of-service", +policyUri = "https://element.io/privacy" + + +Example of OidcData (from presentUrl callback): +url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent + +Formatted url: +https://auth-oidc.lab.element.dev/authorize? + response_type=code& + client_id=01GYCAGG3PA70CJ97ZVP0WFJY3& + redirect_uri=io.element%3A%2Fcallback& + scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG& + state=ex6mNJVFZ5jn9wL8& + nonce=NZ93DOyIGQd9exPQ& + code_challenge_method=S256& + code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U& + prompt=consent + +state: ex6mNJVFZ5jn9wL8 + + +Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs +Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs diff --git a/docs/pull_request.md b/docs/pull_request.md new file mode 100644 index 0000000000..6144dd0d92 --- /dev/null +++ b/docs/pull_request.md @@ -0,0 +1,290 @@ +# Pull requests + +<!--- TOC --> + +* [Introduction](#introduction) +* [Who should read this document?](#who-should-read-this-document?) +* [Submitting PR](#submitting-pr) + * [Who can submit pull requests?](#who-can-submit-pull-requests?) + * [Humans](#humans) + * [Draft PR?](#draft-pr?) + * [Base branch](#base-branch) + * [PR Review Assignment](#pr-review-assignment) + * [PR review time](#pr-review-time) + * [Re-request PR review](#re-request-pr-review) + * [When create split PR?](#when-create-split-pr?) + * [Avoid fixing other unrelated issue in a big PR](#avoid-fixing-other-unrelated-issue-in-a-big-pr) + * [Bots](#bots) + * [Renovate](#renovate) + * [Gradle wrapper](#gradle-wrapper) + * [Sync analytics plan](#sync-analytics-plan) +* [Reviewing PR](#reviewing-pr) + * [Who can review pull requests?](#who-can-review-pull-requests?) + * [What to have in mind when reviewing a PR](#what-to-have-in-mind-when-reviewing-a-pr) + * [Rules](#rules) + * [Check the form](#check-the-form) + * [PR title](#pr-title) + * [PR description](#pr-description) + * [File change](#file-change) + * [Check the commit](#check-the-commit) + * [Check the substance](#check-the-substance) + * [Make a dedicated meeting to review the PR](#make-a-dedicated-meeting-to-review-the-pr) + * [What happen to the issue(s)?](#what-happen-to-the-issues?) + * [Merge conflict](#merge-conflict) + * [When and who can merge PR](#when-and-who-can-merge-pr) + * [Merge type](#merge-type) + * [Resolve conversation](#resolve-conversation) +* [Responsibility](#responsibility) + +<!--- END --> + +## Introduction + +This document gives some clue about how to efficiently manage Pull Requests (PR). This document is a first draft and may be improved later. + +## Who should read this document? + +Every pull request reviewers, but also probably every ones who submit PRs. + +## Submitting PR + +### Who can submit pull requests? + +Basically every one who wants to contribute to the project! But there are some rules to follow. + +#### Humans + +People with write access to the project can directly clone the project, push their branches and create PR. + +External contributors must first fork the project and create PR to the mainline from there. + +##### Draft PR? + +Draft PR can be created when the submitter does not expect the PR to be reviewed and merged yet. It can be useful to publicly show the work, or to do a self-review first. + +Draft PR can also be created when it depends on other un-merged PR. + +In any case, it is better to explicitly declare in the description why the PR is a draft PR. + +Also, draft PR should not stay indefinitely in this state. It may be removed if it is the case and the submitter does not update it after a few days. + +##### Base branch + +The `develop` branch is generally the base branch for every PRs. + +Exceptions can occur: + +- if a feature implementation is split into multiple PRs. We can have a chain of PRs in this case. PR can be merged one by one on develop, and GitHub change the target branch to `develop` for the next PR automatically. +- we want to merge a PR from the community, but there is still work to do, and the PR is not updated by the submitter. First, we can kindly ask the submitter if they will update their PR, by commenting it. If there is no answer after a few days (including a week-end), we can create a new branch, push it, and change the target branch of the PR to this new branch. The PR can then be merged, and we can add more commits to fix the issues. After that a new PR can be created with `develop` as a target branch. + +**Important notice 1:** Releases are created from the `develop` branch. So `develop` branch should always contain a "releasable" source code. So when a feature is being implemented with several PRs, it has to be disabled by default (using a feature flag for instance), until the feature is fully implemented. A last PR to enable the feature can then be created. + +**Important notice 2:** Database migration: some developers and some people from the community are using the nightly build from `develop`. Multiple database migrations should be properly handled for them. It is OK to have multiple migrations between 2 releases, It is not OK to add steps to existing database migrations on `develop`. So for instance `develop` users will migrate from version 11 to version 12, then 13, then 14, and `main` users will do all those steps after they get the app upgrade. + +##### PR Review Assignment + +We use automatic assignment for PR reviews. **A PR is automatically routed by GitHub to one team member** using the round robin algorithm. Additional reviewers can be used for complex changes or when the first reviewer is not confident enough on the changes. +The process is the following: + +- The PR creator selects the [element-x-android-reviewers](https://github.com/orgs/vector-im/teams/element-x-android-reviewers) team as a reviewer. +- GitHub automatically assign the reviewer. If the reviewer is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer. +- Alternatively, the PR creator can directly assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at their PR. +- Reviewers get a notification to make the review: they review the code following the good practice (see the rest of this document). +- After making their own review, if they feel not confident enough, they can ask another person for a full review, or they can tag someone within a PR comment to check specific lines. + +For PRs coming from the community, the issue wrangler can assign either the team [element-x-android-reviewers](https://github.com/orgs/vector-im/teams/element-x-android-reviewers) or any member directly. + +##### PR review time + +As a PR submitter, you deserve a quick review. As a reviewer, you should do your best to unblock others. + +Some tips to achieve it: + +- Set up your GH notifications correctly +- Check your pulls page: [https://github.com/pulls](https://github.com/pulls) +- Check your pending assigned PRs before starting or resuming your day to day tasks +- If you are busy with high priority tasks, inform the author. They will find another developer + +It is hard to define a deadline for a review. It depends on the PR size and the complexity. Let's start with a goal of 24h (working day!) for a PR smaller than 500 lines. If bigger, the submitter and the reviewer should discuss. + +After this time, the submitter can ping the reviewer to get a status of the review. + +##### Re-request PR review + +Once all the remarks have been handled, it's possible to re-request a review from the (same) reviewer to let them know that the PR has been updated the PR is ready to be reviewed again. Use the double arrow next to the reviewer name to do that. + +##### When create split PR? + +To implement big new feature, it may be efficient to split the work into several smaller and scoped PRs. They will be easier to review, and they can be merged on `develop` faster. + +Big PR can take time, and there is a risk of future merge conflict. + +Feature flag can be used to avoid half implemented feature to be available in the application. + +That said, splitting into several PRs should not have the side effect to have more review to do, for instance if some code is added, then finally removed. + +##### Avoid fixing other unrelated issue in a big PR + +Each PR should focus on a single task. If other issues may be fixed when working in the area of it, it's preferable to open a dedicated PR. + +It will have the advantage to be reviewed and merged faster, and not interfere with the main PR. + +It's also applicable for code rework (such as renaming for instance), or code formatting. Sometimes, it is more efficient to extract that work to a dedicated PR, and rebase your branch once this "rework" PR has been merged. + +#### Bots + +Some bots can create PR, but they still have to be reviewed by the team + +##### Renovate + +Renovate is a tool which maintain all our external dependencies up to date. A dedicated PR is created for each new available release for one of our external dependencies. + +To review such PR, you have to + - **IMPORTANT** check the diff files (as always). + - Check the release note. Some existing bugs in Element project may be fixed by the upgrade + - Make sure that the CI is happy + - If the code does not compile (API break for instance), you have to checkout the branch and push new commits + - Do some smoke test, depending of the library which has been upgraded + +For some reasons (like for instance a change in package declaration) the tool sometimes does not upgrade some dependencies. In this case, and when detected, the upgrade has to be done manually. + +##### Gradle wrapper + +`Update Gradle Wrapper` is a tool which can create PR to upgrade our gradle.properties file. +Review such PR is the same recipe than for PR from Dependabot + +##### Sync analytics plan + +This tools imports any update in the analytics plan. See instruction in the PR itself to handle it. +More info can be found in the file [analytics.md](./analytics.md) + +## Reviewing PR + +### Who can review pull requests? + +As an open source project, every one can review each others PR. Of course an approval from internal developer is mandatory for a PR to be merged. +But comment in PR from the community are always appreciated! + +### What to have in mind when reviewing a PR + +1. User experience: is the UX and UI correct? You will probably be the second person to test the new thing, the first one is the developer. +2. Developer experience: does the code look nice and decoupled? No big functions, new classes added to the right module, etc. +3. Code maintenance. A bit similar to point 2. Tricky code must be documented for instance +4. Fork consideration. Will configuration of forks be easy? Some documentation may help in some cases. +5. We are building long term products. "Quick and dirty" code must be avoided. +6. The PR includes new tests for the added code, updated test for the existing code +7. All PRs from external contributors **MUST** include a sign-off. It's in the checklist, and sometimes it's checked by the submitter, but there is actually no sign-off. In this case, ask nicely for a sign-off and request changes (do not approve the PR, even if everything else is fine). + +### Rules + +#### Check the form + +##### PR title + +PR title should describe in one line what's brought by the PR. Reviewer can edit the title if it's not clear enough, or to add suffix like `[BLOCKED]` or similar. Fixing typo is also a good practice, since GitHub search is quite not efficient, so the words must be spelled without any issue. Adding suffix will help when viewing the PR list. + +It's free form, but prefix tags could also be used to help understand what's in the PR. + +Examples of prefixes: +- `[Refacto]` +- `[Feature]` +- `[Bugfix]` +- etc. + +Also, it's still possible to add labels to the PRs, such as `A-` or `T-` labels, even if this is not a strong requirement. We prefer to spend time to add labels on issues. + +##### PR description + +PR description should follow the PR template, and at least provide some context about the code change. + +##### File change + +1. Code should follow the guidelines +2. Code should be formatted correctly +3. XML attribute must be sorted +4. New code is added at the correct location +5. New classes are added to the correct location +6. Naming is correct. Naming is really important, it's considered part of the documentation +7. Architecture is followed. For instance, the logic is in the ViewModel and not in the Fragment +8. There is at least one file for the changelog. Exception if the PR fixes something which has not been released yet. Changelog content should target their audience: `.sdk` extension are mainly targeted for developers, other extensions are targeted for users and forks maintainers. It should generally describe visual change rather than give technical details. More details can be found [here](../CONTRIBUTING.md#changelog). +9. PR includes tests. allScreensTest when applicable, and unit tests +10. Avoid over complicating things. Keep it simple (KISS)! +11. PR contains only the expected change. Sometimes, the diff is showing changes that are already on `develop`. This is not good, submitter has to fix that up. + +##### Check the commit + +Commit message must be short, one line and valuable. "WIP" is not a good commit message. Commit message can contain issue number, starting with `#`. GitHub will add some link between the issue and such commit, which can be useful. It's possible to change a commit message at any time (may require a force push). + +Commit messages can contain extra lines with more details, links, etc. But keep in mind that those lines are quite less visible than the first line. + +Also commit history should be nice. Having commits like "Adding temporary code" then later "Removing temporary code" is not good. The branch has to be rebased and those commit have to be dropped. + +PR merger could decide to squash and merge if commit history is not good. + +Commit like "Code review fixes" is good when reviewing the PR, since new changes can be reviewed easily, but is less valuable when looking at git history. To avoid this, PR submitter should always push new commits after a review (no commit amend with force push), and when the PR is approved decide to interactive rebase the PR to improve the git history and reduce noise. + +##### Check the substance + +1. Test the changes! +2. Test the nominal case and the edge cases +3. Run the sanity test for critical PR + +##### Make a dedicated meeting to review the PR + +Sometimes a big PR can be hard to review. Setting up a call with the PR submitter can speed up the communication, rather than putting comments and questions in GitHub comments. It has the inconvenience of making the discussion non-public, consider including a summary of the main points of the "offline" conversation in the PR. + +### What happen to the issue(s)? + +The issue(s) should be referenced in the PR description using keywords like `Closes` of `Fixes` followed by the issue number. + +Example: +> Closes #1 + +Note that you have to repeat the keyword in case of a list of issue + +> Closes #1, Closes #2, etc. + +When PR will be merged, such referenced issue will be automatically closed. +It is up to the person who has merged the PR to go to the (closed) issue(s) and to add a comment to inform in which version the issue fix will be available. Use the current version of `develop` branch. + +> Closed in Element Android v1.x.y + +### Merge conflict + +It's up to the submitter to handle merge conflict. Sometimes, they can be fixed directly from GitHub, sometimes this is not possible. The branch can be rebased on `develop`, or the `develop` branch can be merged on the branch, it's up to the submitter to decide what is best. +Keep in mind that Github Actions are not run in case of conflict. + +### When and who can merge PR + +PR can be merged by the submitter, if and only if at least one approval from another developer is done. Approval from all people added as reviewer is also a good thing to have. Approval from design team may be mandatory, but is not sufficient to merge a PR. + +PR can also be merged by the reviewer, to reduce the time the PR is open. But only if the PR is not in draft and the change are quite small, or behind a feature flag. + +Dangerous PR should not be merged just before a release. Dangerous PR are PR that could break the app. Update of Realm library, rework in the chunk of Events management in the SDK, etc. + +We prefer to merge such PR after a release so that it can be tested during several days by the team before behind included in a release candidate. + +PR from bots will always be merged by the reviewer, right after approving the changes, or in case of critical changes, right after a release. + +#### Merge type + +Generally we use "Create a merge commit", which has the advantage to keep the branch visible. + +If git history is noisy (code added, then removed, etc.), it's possible to use "Squash and merge". But the branch will not be visible anymore, a commit will be added on top of develop. Git commit message can (and probably must) be edited from the GitHub web app. It's better if the submitter do the work to cleanup the git history by using a git interactive rebase of their branch. + +### Resolve conversation + +Generally we do not close conversation added during PR review and update by clicking on "Resolve conversation" +If the submitter or the reviewer do so, it will more difficult for further readers to see again the content. They will have to open the conversation to see it again. it's a waste of time. + +When remarks are handled, a small comment like "done" is enough, commit hash can also be added to the conversation. + +Exception: for big PRs with lots of conversations, using "Resolve conversation" may help to see the remaining remarks. + +Also "Resolve conversation" should probably be hit by the creator of the conversation. + +## Responsibility + +PR submitter is responsible of the incoming change. PR reviewers who approved the PR take a part of responsibility on the code which will land to develop, and then be used by our users, and the user of our forks. + +That said, bug may still be merged on `develop`, this is still acceptable of course. In this case, please make sure an issue is created and correctly labelled. Ideally, such issues should be fixed before the next release candidate, i.e. with a higher priority. But as we release the application every 10 working days, it can be hard to fix every bugs. That's why PR should be fully tested and reviewed before being merge and we should never comment code review remark with "will be handled later", or similar comments. diff --git a/docs/screenshot_testing.md b/docs/screenshot_testing.md new file mode 100644 index 0000000000..37299af7fc --- /dev/null +++ b/docs/screenshot_testing.md @@ -0,0 +1,59 @@ +# Screenshot testing + +<!--- TOC --> + +* [Overview](#overview) +* [Setup](#setup) +* [Recording](#recording) +* [Verifying](#verifying) +* [Contributing](#contributing) + +<!--- END --> + +## Overview + +- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently. +- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase). +- The screenshot verification occurs on every pull request as part of the `tests.yml` workflow. + +## Setup + +- Install Git LFS through your package manager of choice (`brew install git-lfs` | `yay -S git-lfs`). +- Install the Git LFS hooks into the project. + +```shell +# with element-android as the current working directory +git lfs install --local +``` + +If installed correctly, `git push` and `git pull` will now include LFS content. + +## Recording + +```shell +./gradlew recordPaparazziDebug +``` + +The task will delete the content of the folder `/snapshots` before recording (see the task `removeOldSnapshots` defined in the project). + +If this is not the case, you can run + +```shell +rm -rf ./tests/uitests/src/test/snapshots +``` + +Paparazzi will generate images in `:tests:uitests/src/test/snapshots`, which will need to be committed to the repository using Git LFS. + +## Verifying + +```shell +./gradlew verifyPaparazziDebug +``` + +In the case of failure, Paparazzi will generate images in `:tests:uitests/out/failure`. The images will show the expected and actual screenshots along with a delta of the two images. + +## Contributing + +- Creating Previewable Composable will automatically creates new screenshot tests. +- After creating the new test, record and commit the newly rendered screens. +- `./tools/git/validate_lfs.sh` can be run to ensure everything is working correctly with Git LFS, the CI also runs this check. diff --git a/fastlane/metadata/android/en-US/changelogs/1001000.txt b/fastlane/metadata/android/en-US/changelogs/1001000.txt new file mode 100644 index 0000000000..78dd519c46 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/1001000.txt @@ -0,0 +1 @@ +First release of Element X 🚀! diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000000..7272246de5 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,23 @@ +Element X is the future Element. + +It is the brand new, and fastest ever, Matrix client. It is for personal and community use, and will support enterprise functionality later this year. + +A complete new build, Element X transforms performance. It’s not just the fastest Matrix client, it’s also fresher and more reliable. + +It’s so fast for a number of reasons, but in particular we’ve introduced a completely new syncing service (‘sliding sync’). So even in big end-to-end encrypted chat rooms it operates incredibly quickly. + +It’s fresher because we’ve rebuilt the entire user experience. All the power of Matrix - and the complexity of decentralized end-to-end encryption - is now hidden under a beautiful and intuitive user interface using the very latest frameworks and accessibility features. + +Element X delivers speed, usability and reliability on the decentralized Matrix open standard. + +<b>Own your data</b> +Matrix-based, Element X lets you self-host your data or choose from any free public server (the default is matrix.org, but there are plenty of others to choose from). However you host, you have ownership; it’s your data. You’re not the product. You’re in control. + +<b>Interoperate natively</b> +Enjoy the freedom of the Matrix open standard! You have native interoperability with any other Matrix-based app. So just like email, it doesn't matter if your friends are on a different Matrix-based app you can still connect and chat. + +<b>Encrypt your data</b> +Enjoy your right to private conversations - free from data mining, ads and all the rest of it - and stay secure. Only the people in your conversation can read your messages. And Element X E2EE applies to voice and video calls too. + +<b>Chat across multiple devices</b> +Stay in touch wherever you are with fully synchronized message history across all your devices, even those running ‘traditional’ Element, and on the web at https://app.element.io \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000000..37975de877 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000000..cfe22b43cd Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000000..b1668d1b2d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000000..30d6a71a01 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000000..c62d890ca2 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000000..0a612798a6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000000..c474361017 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Fastest ever Matrix client \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000000..1e66e9042e --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Element X - Secure messenger \ No newline at end of file diff --git a/features/analytics/api/build.gradle.kts b/features/analytics/api/build.gradle.kts new file mode 100644 index 0000000000..3d3a3b9189 --- /dev/null +++ b/features/analytics/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.analytics.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) +} diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt new file mode 100644 index 0000000000..b773754a11 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface AnalyticsEntryPoint : SimpleFeatureEntryPoint diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt new file mode 100644 index 0000000000..0804f8ea44 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api + +sealed interface AnalyticsOptInEvents { + data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvents +} diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt new file mode 100644 index 0000000000..883e0d1dc3 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/Config.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api + +object Config { + const val POLICY_LINK = "https://element.io/cookie-policy" +} + diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt new file mode 100644 index 0000000000..ad7538cafe --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesPresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api.preferences + +import io.element.android.libraries.architecture.Presenter + +interface AnalyticsPreferencesPresenter : Presenter<AnalyticsPreferencesState> diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt new file mode 100644 index 0000000000..7cf0f51dfd --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api.preferences + +import io.element.android.features.analytics.api.AnalyticsOptInEvents + +data class AnalyticsPreferencesState( + val applicationName: String, + val isEnabled: Boolean, + val eventSink: (AnalyticsOptInEvents) -> Unit, +) diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt new file mode 100644 index 0000000000..ea397b4d67 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api.preferences + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<AnalyticsPreferencesState> { + override val values: Sequence<AnalyticsPreferencesState> + get() = sequenceOf( + aAnalyticsPreferencesState().copy(isEnabled = true), + ) +} + +fun aAnalyticsPreferencesState() = AnalyticsPreferencesState( + applicationName = "Element X", + isEnabled = false, + eventSink = {} +) diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt new file mode 100644 index 0000000000..f6d77226b9 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.api.preferences + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.theme.LinkColor +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AnalyticsPreferencesView( + state: AnalyticsPreferencesState, + modifier: Modifier = Modifier, +) { + fun onEnabledChanged(isEnabled: Boolean) { + state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled)) + } + + val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName) + val secondPart = buildAnnotatedStringWithColoredPart( + CommonStrings.screen_analytics_settings_read_terms, + CommonStrings.screen_analytics_settings_read_terms_content_link + ) + val subtitle = "$firstPart\n\n$secondPart" + + PreferenceSwitch( + modifier = modifier, + title = stringResource(id = CommonStrings.screen_analytics_settings_share_data), + subtitle = subtitle, + isChecked = state.isEnabled, + onCheckedChange = ::onEnabledChanged, + switchAlignment = Alignment.Top, + ) +} + +@Composable +fun buildAnnotatedStringWithColoredPart( + @StringRes fullTextRes: Int, + @StringRes coloredTextRes: Int, + color: Color = LinkColor, + underline: Boolean = true, +) = buildAnnotatedString { + val coloredPart = stringResource(coloredTextRes) + val fullText = stringResource(fullTextRes, coloredPart) + val startIndex = fullText.indexOf(coloredPart) + append(fullText) + addStyle( + style = SpanStyle( + color = color, + textDecoration = if (underline) TextDecoration.Underline else null + ), start = startIndex, end = startIndex + coloredPart.length + ) +} + +@Preview +@Composable +fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: AnalyticsPreferencesState) { + AnalyticsPreferencesView(state) +} diff --git a/features/analytics/impl/build.gradle.kts b/features/analytics/impl/build.gradle.kts new file mode 100644 index 0000000000..3bf58ab636 --- /dev/null +++ b/features/analytics/impl/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.analytics.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.features.analytics.api) + api(projects.services.analytics.api) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.browser) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.mockk) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.analytics.test) + testImplementation(projects.features.analytics.impl) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt new file mode 100644 index 0000000000..ab060a51cf --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import android.app.Activity +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.analytics.api.Config +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class AnalyticsOptInNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: AnalyticsOptInPresenter, +) : Node(buildContext, plugins = plugins) { + + private fun onClickTerms(activity: Activity, darkTheme: Boolean) { + activity.openUrlInChromeCustomTab(null, darkTheme, Config.POLICY_LINK) + } + + @Composable + override fun View(modifier: Modifier) { + val activity = LocalContext.current as Activity + val isDark = MaterialTheme.colors.isLight.not() + val state = presenter.present() + AnalyticsOptInView( + state = state, + modifier = modifier, + onClickTerms = { onClickTerms(activity, isDark) }, + ) + } +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt new file mode 100644 index 0000000000..3cd2203dbe --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class AnalyticsOptInPresenter @Inject constructor( + private val buildMeta: BuildMeta, + private val analyticsService: AnalyticsService, +) : Presenter<AnalyticsOptInState> { + + @Composable + override fun present(): AnalyticsOptInState { + val localCoroutineScope = rememberCoroutineScope() + + fun handleEvents(event: AnalyticsOptInEvents) { + when (event) { + is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) + } + localCoroutineScope.launch { + analyticsService.setDidAskUserConsent() + } + } + + return AnalyticsOptInState( + applicationName = buildMeta.applicationName, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch { + analyticsService.setUserConsent(enabled) + } +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt new file mode 100644 index 0000000000..a12cbaa7ea --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import io.element.android.features.analytics.api.AnalyticsOptInEvents + +data class AnalyticsOptInState( + val applicationName: String, + val eventSink: (AnalyticsOptInEvents) -> Unit +) diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt new file mode 100644 index 0000000000..544e4a5649 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import javax.inject.Inject + +open class AnalyticsOptInStateProvider @Inject constructor( +) : PreviewParameterProvider<AnalyticsOptInState> { + override val values: Sequence<AnalyticsOptInState> + get() = sequenceOf( + aAnalyticsOptInState(), + ) +} + +fun aAnalyticsOptInState() = AnalyticsOptInState( + applicationName = "Element X", + eventSink = {} +) diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt new file mode 100644 index 0000000000..a27e6e7399 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Poll +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem +import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun AnalyticsOptInView( + state: AnalyticsOptInState, + onClickTerms: () -> Unit, + modifier: Modifier = Modifier, +) { + LogCompositions(tag = "Analytics", msg = "Root") + val eventSink = state.eventSink + + fun onTermsAccepted() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) + } + + fun onTermsDeclined() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) + } + + BackHandler(onBack = ::onTermsDeclined) + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + header = { AnalyticsOptInHeader(state, onClickTerms) }, + content = { AnalyticsOptInContent() }, + footer = { + AnalyticsOptInFooter( + onTermsAccepted = ::onTermsAccepted, + onTermsDeclined = ::onTermsDeclined, + ) + } + ) +} + +@Composable +private fun AnalyticsOptInHeader( + state: AnalyticsOptInState, + onClickTerms: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), + title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), + subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), + iconImageVector = Icons.Filled.Poll + ) + Text( + text = buildAnnotatedStringWithStyledPart( + R.string.screen_analytics_prompt_read_terms, + R.string.screen_analytics_prompt_read_terms_content_link, + color = Color.Unspecified, + underline = false, + bold = true, + ), + modifier = Modifier + .clip(shape = RoundedCornerShape(8.dp)) + .clickable { onClickTerms() } + .padding(8.dp), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.secondary, + ) + } +} + +@Composable +private fun CheckIcon(modifier: Modifier = Modifier) { + Icon( + modifier = modifier + .size(20.dp) + .background(color = MaterialTheme.colorScheme.background, shape = CircleShape) + .padding(2.dp), + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = ElementTheme.colors.textActionAccent, + ) +} + +@Composable +private fun AnalyticsOptInContent( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_data_usage), + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), + iconComposable = { CheckIcon() }, + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_settings), + iconComposable = { CheckIcon() }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.textPrimary, + backgroundColor = ElementTheme.colors.temporaryColorBgSpecial + ) + } +} + +@Composable +private fun AnalyticsOptInFooter( + onTermsAccepted: () -> Unit, + onTermsDeclined: () -> Unit, + modifier: Modifier = Modifier, +) { + ButtonColumnMolecule( + modifier = modifier, + ) { + Button( + onClick = onTermsAccepted, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = CommonStrings.action_ok)) + } + TextButton( + onClick = onTermsDeclined, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(id = CommonStrings.action_not_now)) + } + } +} + +@Preview +@Composable +fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight { + ContentToPreview(state) +} + +@Preview +@Composable +fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark { + ContentToPreview(state) +} + +@Composable +private fun ContentToPreview(state: AnalyticsOptInState) { + AnalyticsOptInView( + state = state, + onClickTerms = {}, + ) +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt new file mode 100644 index 0000000000..6b2e26f763 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.analytics.api.AnalyticsEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAnalyticsEntryPoint @Inject constructor() : AnalyticsEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode<AnalyticsOptInNode>(buildContext) + } +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt new file mode 100644 index 0000000000..6debe4c232 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/DefaultAnalyticsPreferencesPresenter.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAnalyticsPreferencesPresenter @Inject constructor( + private val analyticsService: AnalyticsService, + private val buildMeta: BuildMeta, +) : AnalyticsPreferencesPresenter { + + @Composable + override fun present(): AnalyticsPreferencesState { + val localCoroutineScope = rememberCoroutineScope() + val isEnabled = analyticsService.getUserConsent() + .collectAsState(initial = false) + + fun handleEvents(event: AnalyticsOptInEvents) { + when (event) { + is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) + } + } + + return AnalyticsPreferencesState( + applicationName = buildMeta.applicationName, + isEnabled = isEnabled.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch { + analyticsService.setUserConsent(enabled) + } +} diff --git a/features/analytics/impl/src/main/res/values-cs/translations.xml b/features/analytics/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..b75b359216 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"Nezaznamenáváme ani neprofilujeme žádné údaje o účtu"</string> + <string name="screen_analytics_prompt_help_us_improve">"Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy."</string> + <string name="screen_analytics_prompt_read_terms">"Můžete si přečíst všechny naše podmínky %1$s."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"zde"</string> + <string name="screen_analytics_prompt_settings">"Tuto funkci můžete kdykoli vypnout"</string> + <string name="screen_analytics_prompt_third_party_sharing">"Nesdílíme informace s třetími stranami"</string> + <string name="screen_analytics_prompt_title">"Pomozte vylepšit %1$s"</string> +</resources> diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..979048344f --- /dev/null +++ b/features/analytics/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string> + <string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string> + <string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"hier"</string> + <string name="screen_analytics_prompt_settings">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string> + <string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string> + <string name="screen_analytics_prompt_title">"Helfen Sie %1$s zu verbessern"</string> +</resources> diff --git a/features/analytics/impl/src/main/res/values-fr/translations.xml b/features/analytics/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..55231f7b6c --- /dev/null +++ b/features/analytics/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"Nous n\'enregistrerons ni ne traiterons aucune donnée personnelle"</string> + <string name="screen_analytics_prompt_help_us_improve">"Partagez des données d\'utilisation anonymes pour nous aider à identifier les problèmes."</string> + <string name="screen_analytics_prompt_read_terms">"Consultez nos conditions d\'utilisation %1$s."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"ici"</string> + <string name="screen_analytics_prompt_settings">"Vous pouvez désactiver cette fonction à tout moment"</string> + <string name="screen_analytics_prompt_third_party_sharing">"Nous ne partagerons pas vos données avec des tiers"</string> + <string name="screen_analytics_prompt_title">"Aidez-nous à améliorer %1$s"</string> +</resources> diff --git a/features/analytics/impl/src/main/res/values-ro/translations.xml b/features/analytics/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..f9fd53a184 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"Nu vom înregistra și nu vom face profiluri cu privire la datele personale."</string> + <string name="screen_analytics_prompt_help_us_improve">"Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string> + <string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"aici"</string> + <string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string> + <string name="screen_analytics_prompt_third_party_sharing">"Nu vom partaja datele dvs. cu terțe părți"</string> + <string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string> +</resources> diff --git a/features/analytics/impl/src/main/res/values-sk/translations.xml b/features/analytics/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..d16c34dc60 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"Nezaznamenávame ani neprofilujeme žiadne osobné údaje"</string> + <string name="screen_analytics_prompt_help_us_improve">"Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy."</string> + <string name="screen_analytics_prompt_read_terms">"Môžete si prečítať všetky naše podmienky %1$s."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"tu"</string> + <string name="screen_analytics_prompt_settings">"Môžete to kedykoľvek vypnúť"</string> + <string name="screen_analytics_prompt_third_party_sharing">"Vaše údaje nebudeme zdieľať s tretími stranami"</string> + <string name="screen_analytics_prompt_title">"Pomôžte zlepšiť %1$s"</string> +</resources> diff --git a/features/analytics/impl/src/main/res/values/localazy.xml b/features/analytics/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..a496bdd0c6 --- /dev/null +++ b/features/analytics/impl/src/main/res/values/localazy.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_analytics_prompt_data_usage">"We won\'t record or profile any personal data"</string> + <string name="screen_analytics_prompt_help_us_improve">"Share anonymous usage data to help us identify issues."</string> + <string name="screen_analytics_prompt_read_terms">"You can read all our terms %1$s."</string> + <string name="screen_analytics_prompt_read_terms_content_link">"here"</string> + <string name="screen_analytics_prompt_settings">"You can turn this off anytime"</string> + <string name="screen_analytics_prompt_third_party_sharing">"We won\'t share your data with third parties"</string> + <string name="screen_analytics_prompt_title">"Help improve %1$s"</string> +</resources> diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt new file mode 100644 index 0000000000..a8e42ceb01 --- /dev/null +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AnalyticsOptInPresenterTest { + @Test + fun `present - enable`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = false) + val presenter = AnalyticsOptInPresenter( + aBuildMeta(), + analyticsService + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(analyticsService.didAskUserConsent().first()).isFalse() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) + assertThat(analyticsService.didAskUserConsent().first()).isTrue() + assertThat(analyticsService.getUserConsent().first()).isTrue() + } + } + + @Test + fun `present - not now`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = false) + val presenter = AnalyticsOptInPresenter( + aBuildMeta(), + analyticsService + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(analyticsService.didAskUserConsent().first()).isFalse() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) + assertThat(analyticsService.didAskUserConsent().first()).isTrue() + assertThat(analyticsService.getUserConsent().first()).isFalse() + } + } +} + diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt new file mode 100644 index 0000000000..8469abe769 --- /dev/null +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.impl.preferences + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AnalyticsPreferencesPresenterTest { + @Test + fun `present - initial state available`() = runTest { + val presenter = DefaultAnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), + aBuildMeta() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnabled).isTrue() + } + } + + @Test + fun `present - initial state not available`() = runTest { + val presenter = DefaultAnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = false, didAskUserConsent = false), + aBuildMeta() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isEnabled).isFalse() + } + } + + @Test + fun `present - enable and disable`() = runTest { + val presenter = DefaultAnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), + aBuildMeta() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnabled).isTrue() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) + assertThat(awaitItem().isEnabled).isFalse() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) + assertThat(awaitItem().isEnabled).isTrue() + } + } +} + diff --git a/features/analytics/test/build.gradle.kts b/features/analytics/test/build.gradle.kts new file mode 100644 index 0000000000..9f1796b156 --- /dev/null +++ b/features/analytics/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.analytics.test" +} + +dependencies { + implementation(projects.services.analytics.api) + implementation(projects.libraries.core) + implementation(libs.coroutines.core) +} diff --git a/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt new file mode 100644 index 0000000000..6e84c58d2a --- /dev/null +++ b/features/analytics/test/src/main/kotlin/io/element/android/features/analytics/test/FakeAnalyticsService.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.analytics.test + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeAnalyticsService( + isEnabled: Boolean = false, + didAskUserConsent: Boolean = false +): AnalyticsService { + + private val isEnabledFlow = MutableStateFlow(isEnabled) + private val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) + val capturedEvents = mutableListOf<VectorAnalyticsEvent>() + + override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> = emptyList() + + override fun getUserConsent(): Flow<Boolean> = isEnabledFlow + + override suspend fun setUserConsent(userConsent: Boolean) { + isEnabledFlow.value = userConsent + } + + override fun didAskUserConsent(): Flow<Boolean> = didAskUserConsentFlow + + override suspend fun setDidAskUserConsent() { + didAskUserConsentFlow.value = true + } + + override fun getAnalyticsId(): Flow<String> = MutableStateFlow("") + + override suspend fun setAnalyticsId(analyticsId: String) { + } + + override suspend fun onSignOut() { + } + + override fun capture(event: VectorAnalyticsEvent) { + capturedEvents += event + } + + override fun screen(screen: VectorAnalyticsScreen) { + } + + override fun updateUserProperties(userProperties: UserProperties) { + } + + override fun trackError(throwable: Throwable) { + } + + override suspend fun reset() { + didAskUserConsentFlow.value = false + } +} diff --git a/features/createroom/api/build.gradle.kts b/features/createroom/api/build.gradle.kts new file mode 100644 index 0000000000..a1ec16ef36 --- /dev/null +++ b/features/createroom/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.createroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt new file mode 100644 index 0000000000..18e0e4e28f --- /dev/null +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId + +interface CreateRoomEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onSuccess(roomId: RoomId) + } +} diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts new file mode 100644 index 0000000000..8bb343c10a --- /dev/null +++ b/features/createroom/impl/build.gradle.kts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.createroom.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.deeplink) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.usersearch.impl) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + api(projects.features.createroom.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(projects.features.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.usersearch.test) + + androidTestImplementation(libs.test.junitext) + + ksp(libs.showkase.processor) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt new file mode 100644 index 0000000000..a5a78e54d5 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.impl.addpeople.AddPeopleNode +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode +import io.element.android.features.createroom.impl.di.CreateRoomComponent +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class ConfigureRoomFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : DaggerComponentOwner, + BackstackNode<ConfigureRoomFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + + private val component by lazy { + parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build() + } + + override val daggerComponent: Any + get() = component + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + + @Parcelize + object ConfigureRoom : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : AddPeopleNode.Callback { + override fun onContinue() { + backstack.push(NavTarget.ConfigureRoom) + } + } + createNode<AddPeopleNode>(context = buildContext, plugins = listOf(callback)) + } + NavTarget.ConfigureRoom -> { + val callbacks = plugins<ConfigureRoomNode.Callback>() + createNode<ConfigureRoomNode>(context = buildContext, plugins = callbacks) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler() + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt new file mode 100644 index 0000000000..8f6f6e14d9 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl + +import android.net.Uri +import io.element.android.features.createroom.impl.configureroom.RoomPrivacy +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class CreateRoomConfig( + val roomName: String? = null, + val topic: String? = null, + val avatarUri: Uri? = null, + val invites: ImmutableList<MatrixUser> = persistentListOf(), + val privacy: RoomPrivacy = RoomPrivacy.Private, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt new file mode 100644 index 0000000000..f79284d082 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl + +import android.net.Uri +import io.element.android.features.createroom.impl.configureroom.RoomPrivacy +import io.element.android.features.createroom.impl.di.CreateRoomScope +import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.di.SingleIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import java.io.File +import javax.inject.Inject + +@SingleIn(CreateRoomScope::class) +class CreateRoomDataStore @Inject constructor( + val selectedUserListDataStore: UserListDataStore, +) { + + private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig()) + private var cachedAvatarUri: Uri? = null + set(value) { + field?.path?.let { File(it) }?.safeDelete() + field = value + } + + fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine( + selectedUserListDataStore.selectedUsers(), + createRoomConfigFlow, + ) { selectedUsers, config -> + config.copy(invites = selectedUsers.toImmutableList()) + } + + fun setRoomName(roomName: String?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() })) + } + + fun setTopic(topic: String?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) + } + + fun setAvatarUri(uri: Uri?, cached: Boolean = false) { + cachedAvatarUri = uri.takeIf { cached } + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri)) + } + + fun setPrivacy(privacy: RoomPrivacy) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) + } + + fun clearCachedData() { + cachedAvatarUri = null + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt new file mode 100644 index 0000000000..6f447e6bc9 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode +import io.element.android.features.createroom.impl.root.CreateRoomRootNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class CreateRoomFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : BackstackNode<CreateRoomFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + + @Parcelize + object NewRoom : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : CreateRoomRootNode.Callback { + override fun onCreateNewRoom() { + backstack.push(NavTarget.NewRoom) + } + + override fun onStartChatSuccess(roomId: RoomId) { + plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) } + } + } + createNode<CreateRoomRootNode>(context = buildContext, plugins = listOf(callback)) + } + NavTarget.NewRoom -> { + val callback = object : ConfigureRoomNode.Callback { + override fun onCreateRoomSuccess(roomId: RoomId) { + plugins<CreateRoomEntryPoint.Callback>().forEach { it.onSuccess(roomId) } + } + } + createNode<ConfigureRoomFlowNode>(context = buildContext, plugins = listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler() + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt new file mode 100644 index 0000000000..34e514be3e --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder { + + val plugins = ArrayList<Plugin>() + + return object : CreateRoomEntryPoint.NodeBuilder { + + override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins) + } + } + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt new file mode 100644 index 0000000000..1b4bd9ac8d --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.addpeople + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.impl.di.CreateRoomScope + +@ContributesNode(CreateRoomScope::class) +class AddPeopleNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: AddPeoplePresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onContinue() + } + + private fun onContinue() { + plugins<Callback>().forEach { it.onContinue() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AddPeopleView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp, + onNextPressed = this::onContinue, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt new file mode 100644 index 0000000000..0927e193ad --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.addpeople + +import androidx.compose.runtime.Composable +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.createroom.impl.userlist.SelectionMode +import io.element.android.features.createroom.impl.userlist.UserListPresenter +import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs +import io.element.android.features.createroom.impl.userlist.UserListState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.usersearch.api.UserRepository +import javax.inject.Inject + +class AddPeoplePresenter @Inject constructor( + private val userListPresenterFactory: UserListPresenter.Factory, + private val userRepository: UserRepository, + private val dataStore: CreateRoomDataStore, +) : Presenter<UserListState> { + + private val userListPresenter by lazy { + userListPresenterFactory.create( + UserListPresenterArgs( + selectionMode = SelectionMode.Multiple, + ), + userRepository, + dataStore.selectedUserListDataStore, + ) + } + + @Composable + override fun present(): UserListState { + return userListPresenter.present() + } +} + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt new file mode 100644 index 0000000000..48ad56caf4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.addpeople + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.createroom.impl.userlist.SelectionMode +import io.element.android.features.createroom.impl.userlist.UserListState +import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers +import io.element.android.features.createroom.impl.userlist.aUserListState +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toImmutableList + +open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListState> { + override val values: Sequence<UserListState> + get() = sequenceOf( + aUserListState(), + aUserListState().copy( + searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), + selectedUsers = aListOfSelectedUsers(), + isSearchActive = false, + selectionMode = SelectionMode.Multiple, + ), + aUserListState().copy( + searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), + selectedUsers = aListOfSelectedUsers(), + isSearchActive = true, + selectionMode = SelectionMode.Multiple, + ) + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt new file mode 100644 index 0000000000..da1f43391b --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.addpeople + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.R +import io.element.android.features.createroom.impl.components.UserListView +import io.element.android.features.createroom.impl.userlist.UserListEvents +import io.element.android.features.createroom.impl.userlist.UserListState +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddPeopleView( + state: UserListState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onNextPressed: () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = { + AddPeopleViewTopBar( + hasSelectedUsers = state.selectedUsers.isNotEmpty(), + onBackPressed = { + if (state.isSearchActive) { + state.eventSink(UserListEvents.OnSearchActiveChanged(false)) + } else { + onBackPressed() + } + }, + onNextPressed = onNextPressed, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding), + ) { + UserListView( + modifier = Modifier + .fillMaxWidth(), + state = state, + showBackButton = false, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddPeopleViewTopBar( + hasSelectedUsers: Boolean, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onNextPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(id = R.string.screen_create_room_add_people_title), + style = ElementTheme.typography.aliasScreenTitle + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = onNextPressed, + ) { + val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip + Text( + text = stringResource(id = textActionResId), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + ) +} + +@Preview +@Composable +internal fun AddPeopleViewLightPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun AddPeopleViewDarkPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: UserListState) { + AddPeopleView(state = state) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt new file mode 100644 index 0000000000..ee664673f8 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem +import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.RadioButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun RoomPrivacyOption( + roomPrivacyItem: RoomPrivacyItem, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onOptionSelected: (RoomPrivacyItem) -> Unit = {}, +) { + Row( + modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { onOptionSelected(roomPrivacyItem) }, + role = Role.RadioButton, + ) + .padding(8.dp), + ) { + Icon( + modifier = Modifier.padding(horizontal = 8.dp), + imageVector = roomPrivacyItem.icon, + contentDescription = "", + tint = MaterialTheme.colorScheme.secondary, + ) + + Column( + Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = roomPrivacyItem.title, + style = ElementTheme.typography.fontBodyLgRegular, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(3.dp)) + Text( + text = roomPrivacyItem.description, + style = ElementTheme.typography.fontBodySmRegular, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + RadioButton( + modifier = Modifier + .align(Alignment.CenterVertically) + .size(48.dp), + selected = isSelected, + onClick = null // null recommended for accessibility with screenreaders + ) + } +} + +@Preview +@Composable +fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + val aRoomPrivacyItem = roomPrivacyItems().first() + Column { + RoomPrivacyOption( + roomPrivacyItem = aRoomPrivacyItem, + isSelected = true, + ) + RoomPrivacyOption( + roomPrivacyItem = aRoomPrivacyItem, + isSelected = false, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt new file mode 100644 index 0000000000..7b726e08a2 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow +import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.usersearch.api.UserSearchResult + +@Composable +fun SearchMultipleUsersResultItem( + searchResult: UserSearchResult, + isUserSelected: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit = {}, +) { + if (searchResult.isUnresolved) { + CheckableUnresolvedUserRow( + checked = isUserSelected, + modifier = modifier, + avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem), + id = searchResult.matrixUser.userId.value, + onCheckedChange = onCheckedChange, + ) + } else { + CheckableMatrixUserRow( + checked = isUserSelected, + modifier = modifier, + matrixUser = searchResult.matrixUser, + avatarSize = AvatarSize.UserListItem, + onCheckedChange = onCheckedChange, + ) + } +} + +@Preview +@Composable +internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false) + Divider() + SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true) + Divider() + SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false) + Divider() + SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt new file mode 100644 index 0000000000..72ebee9615 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.usersearch.api.UserSearchResult + +@Composable +fun SearchSingleUserResultItem( + searchResult: UserSearchResult, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + if (searchResult.isUnresolved) { + UnresolvedUserRow( + modifier = modifier.clickable(onClick = onClick), + avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem), + id = searchResult.matrixUser.userId.value, + ) + } else { + MatrixUserRow( + modifier = modifier.clickable(onClick = onClick), + matrixUser = searchResult.matrixUser, + avatarSize = AvatarSize.UserListItem, + ) + } +} + +@Preview +@Composable +internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false)) + Divider() + SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true)) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt new file mode 100644 index 0000000000..fdcd8900b4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.SelectedUsersList +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchUserBar( + query: String, + state: SearchBarResultState<ImmutableList<UserSearchResult>>, + selectedUsers: ImmutableList<MatrixUser>, + active: Boolean, + isMultiSelectionEnabled: Boolean, + modifier: Modifier = Modifier, + showBackButton: Boolean = true, + placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), + onActiveChanged: (Boolean) -> Unit = {}, + onTextChanged: (String) -> Unit = {}, + onUserSelected: (MatrixUser) -> Unit = {}, + onUserDeselected: (MatrixUser) -> Unit = {}, +) { + val columnState = rememberLazyListState() + + SearchBar( + query = query, + onQueryChange = onTextChanged, + active = active, + onActiveChange = onActiveChanged, + modifier = modifier, + placeHolderTitle = placeHolderTitle, + showBackButton = showBackButton, + contentPrefix = { + if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { + // We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour + // should change to indicate elevation. + + val elevation = remember { + derivedStateOf { + if (columnState.canScrollBackward) { + 4.dp + } else { + 0.dp + } + } + } + + val appBarContainerColor by animateColorAsState( + targetValue = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation.value), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + + SelectedUsersList( + contentPadding = PaddingValues(16.dp), + selectedUsers = selectedUsers, + autoScroll = true, + onUserRemoved = onUserDeselected, + modifier = Modifier.background(appBarContainerColor) + ) + } + }, + resultState = state, + resultHandler = { users -> + LazyColumn(state = columnState) { + if (isMultiSelectionEnabled) { + itemsIndexed(users) { index, searchResult -> + SearchMultipleUsersResultItem( + modifier = Modifier.fillMaxWidth(), + searchResult = searchResult, + isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null, + onCheckedChange = { checked -> + if (checked) { + onUserSelected(searchResult.matrixUser) + } else { + onUserDeselected(searchResult.matrixUser) + } + } + ) + if (index < users.lastIndex) { + Divider() + } + } + } else { + itemsIndexed(users) { index, searchResult -> + SearchSingleUserResultItem( + modifier = Modifier.fillMaxWidth(), + searchResult = searchResult, + onClick = { onUserSelected(searchResult.matrixUser) } + ) + if (index < users.lastIndex) { + Divider() + } + } + } + } + }, + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt new file mode 100644 index 0000000000..7d543261fc --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.userlist.UserListEvents +import io.element.android.features.createroom.impl.userlist.UserListState +import io.element.android.features.createroom.impl.userlist.UserListStateProvider +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.SelectedUsersList + +@Composable +fun UserListView( + state: UserListState, + modifier: Modifier = Modifier, + showBackButton: Boolean = true, + onUserSelected: (MatrixUser) -> Unit = {}, + onUserDeselected: (MatrixUser) -> Unit = {}, +) { + Column( + modifier = modifier, + ) { + SearchUserBar( + modifier = Modifier.fillMaxWidth(), + query = state.searchQuery, + state = state.searchResults, + selectedUsers = state.selectedUsers, + active = state.isSearchActive, + isMultiSelectionEnabled = state.isMultiSelectionEnabled, + showBackButton = showBackButton, + onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, + onUserSelected = { + state.eventSink(UserListEvents.AddToSelection(it)) + onUserSelected(it) + }, + onUserDeselected = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onUserDeselected(it) + }, + ) + + if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) { + SelectedUsersList( + contentPadding = PaddingValues(16.dp), + selectedUsers = state.selectedUsers, + autoScroll = true, + onUserRemoved = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onUserDeselected(it) + }, + ) + } + } +} + +@Preview +@Composable +internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: UserListState) { + UserListView(state = state) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt new file mode 100644 index 0000000000..a020b387cb --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.media.AvatarAction + +sealed interface ConfigureRoomEvents { + data class RoomNameChanged(val name: String) : ConfigureRoomEvents + data class TopicChanged(val topic: String) : ConfigureRoomEvents + data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents + data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents + data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents + data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents + object CancelCreateRoom : ConfigureRoomEvents +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt new file mode 100644 index 0000000000..b09863b205 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.impl.di.CreateRoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(CreateRoomScope::class) +class ConfigureRoomNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: ConfigureRoomPresenter, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom)) + } + ) + } + + interface Callback : Plugin { + fun onCreateRoomSuccess(roomId: RoomId) + } + + private fun onRoomCreated(roomId: RoomId) { + plugins<Callback>().forEach { it.onCreateRoomSuccess(roomId) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ConfigureRoomView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp, + onRoomCreated = this::onRoomCreated, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt new file mode 100644 index 0000000000..21d8f8d13f --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters +import io.element.android.libraries.matrix.api.createroom.RoomPreset +import io.element.android.libraries.matrix.api.createroom.RoomVisibility +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ConfigureRoomPresenter @Inject constructor( + private val dataStore: CreateRoomDataStore, + private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, + private val analyticsService: AnalyticsService, +) : Presenter<ConfigureRoomState> { + + @Composable + override fun present(): ConfigureRoomState { + val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) + + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) }, + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) } + ) + + val avatarActions by remember(createRoomConfig.value.avatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null }, + ).toImmutableList() + } + } + + val localCoroutineScope = rememberCoroutineScope() + val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } + + fun createRoom(config: CreateRoomConfig) { + createRoomAction.value = Async.Uninitialized + localCoroutineScope.createRoom(config, createRoomAction) + } + + fun handleEvents(event: ConfigureRoomEvents) { + when (event) { + is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) + is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) + is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) + is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) + is ConfigureRoomEvents.CreateRoom -> createRoom(event.config) + is ConfigureRoomEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) + } + } + + ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized + } + } + + return ConfigureRoomState( + config = createRoomConfig.value, + avatarActions = avatarActions, + createRoomAction = createRoomAction.value, + eventSink = ::handleEvents, + ) + } + + private fun CoroutineScope.createRoom( + config: CreateRoomConfig, + createRoomAction: MutableState<Async<RoomId>> + ) = launch { + suspend { + val avatarUrl = config.avatarUri?.let { uploadAvatar(it) } + val params = CreateRoomParameters( + name = config.roomName, + topic = config.topic, + isEncrypted = config.privacy == RoomPrivacy.Private, + isDirect = false, + visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, + preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, + invite = config.invites.map { it.userId }, + avatar = avatarUrl, + ) + matrixClient.createRoom(params).getOrThrow() + .also { + dataStore.clearCachedData() + analyticsService.capture(CreatedRoom(isDM = false)) + } + }.runCatchingUpdatingState(createRoomAction) + } + + private suspend fun uploadAvatar(avatarUri: Uri): String { + val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() + val byteArray = preprocessed.file.readBytes() + return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow() + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt new file mode 100644 index 0000000000..5e52668a3d --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class ConfigureRoomPresenterArgs( + val selectedUsers: List<MatrixUser>, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt new file mode 100644 index 0000000000..2e34f3bda2 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList + +data class ConfigureRoomState( + val config: CreateRoomConfig, + val avatarActions: ImmutableList<AvatarAction>, + val createRoomAction: Async<RoomId>, + val eventSink: (ConfigureRoomEvents) -> Unit +) { + val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not() +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt new file mode 100644 index 0000000000..0e31e9e1c0 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers +import io.element.android.libraries.architecture.Async +import kotlinx.collections.immutable.persistentListOf + +open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { + override val values: Sequence<ConfigureRoomState> + get() = sequenceOf( + aConfigureRoomState(), + aConfigureRoomState().copy( + config = CreateRoomConfig( + roomName = "Room 101", + topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines", + invites = aListOfSelectedUsers(), + privacy = RoomPrivacy.Public, + ), + ), + ) +} + +fun aConfigureRoomState() = ConfigureRoomState( + config = CreateRoomConfig(), + avatarActions = persistentListOf(), + createRoomAction = Async.Uninitialized, + eventSink = { }, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt new file mode 100644 index 0000000000..9ac8f0fbde --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.R +import io.element.android.features.createroom.impl.components.RoomPrivacyOption +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.LabelledTextField +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet +import io.element.android.libraries.matrix.ui.components.SelectedUsersList +import io.element.android.libraries.matrix.ui.components.UnsavedAvatar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class) +@Composable +fun ConfigureRoomView( + state: ConfigureRoomState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onRoomCreated: (RoomId) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val itemActionsBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + + if (state.createRoomAction is Async.Success) { + LaunchedEffect(state.createRoomAction) { + onRoomCreated(state.createRoomAction.data) + } + } + + fun onAvatarClicked() { + focusManager.clearFocus() + coroutineScope.launch { + itemActionsBottomSheetState.show() + } + } + + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + ConfigureRoomToolbar( + isNextActionEnabled = state.isCreateButtonEnabled, + onBackPressed = onBackPressed, + onNextPressed = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) + }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + RoomNameWithAvatar( + modifier = Modifier.padding(horizontal = 16.dp), + avatarUri = state.config.avatarUri, + roomName = state.config.roomName.orEmpty(), + onAvatarClick = ::onAvatarClicked, + onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, + ) + RoomTopic( + modifier = Modifier.padding(horizontal = 16.dp), + topic = state.config.topic.orEmpty(), + onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, + ) + if (state.config.invites.isNotEmpty()) { + SelectedUsersList( + modifier = Modifier.padding(bottom = 16.dp), + contentPadding = PaddingValues(horizontal = 24.dp), + selectedUsers = state.config.invites, + onUserRemoved = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) + }, + ) + } + RoomPrivacyOptions( + modifier = Modifier.padding(bottom = 40.dp), + selected = state.config.privacy, + onOptionSelected = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) + }, + ) + } + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + modalBottomSheetState = itemActionsBottomSheetState, + onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } + ) + + when (state.createRoomAction) { + is Async.Loading -> { + ProgressDialog(text = stringResource(CommonStrings.common_creating_room)) + } + + is Async.Failure -> { + RetryDialog( + content = stringResource(R.string.screen_create_room_error_creating_room), + onDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, + onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, + ) + } + + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigureRoomToolbar( + isNextActionEnabled: Boolean, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onNextPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.screen_create_room_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + modifier = Modifier.padding(horizontal = 8.dp), + enabled = isNextActionEnabled, + onClick = onNextPressed, + ) { + Text( + text = stringResource(CommonStrings.action_create), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + ) +} + +@Composable +fun RoomNameWithAvatar( + avatarUri: Uri?, + roomName: String, + modifier: Modifier = Modifier, + onAvatarClick: () -> Unit = {}, + onRoomNameChanged: (String) -> Unit = {}, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UnsavedAvatar( + avatarUri = avatarUri, + modifier = Modifier.clickable(onClick = onAvatarClick), + ) + + LabelledTextField( + label = stringResource(R.string.screen_create_room_room_name_label), + value = roomName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + onValueChange = onRoomNameChanged, + ) + } +} + +@Composable +fun RoomTopic( + topic: String, + modifier: Modifier = Modifier, + onTopicChanged: (String) -> Unit = {}, +) { + LabelledTextField( + modifier = modifier, + label = stringResource(R.string.screen_create_room_topic_label), + value = topic, + placeholder = stringResource(CommonStrings.common_topic_placeholder), + onValueChange = onTopicChanged, + maxLines = 3, + ) +} + +@Composable +fun RoomPrivacyOptions( + selected: RoomPrivacy?, + modifier: Modifier = Modifier, + onOptionSelected: (RoomPrivacyItem) -> Unit = {}, +) { + val items = roomPrivacyItems() + Column(modifier = modifier.selectableGroup()) { + items.forEach { item -> + RoomPrivacyOption( + roomPrivacyItem = item, + isSelected = selected == item.privacy, + onOptionSelected = onOptionSelected, + ) + } + } +} + +private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = + pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } + +@Preview +@Composable +fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ConfigureRoomState) { + ConfigureRoomView( + state = state, + ) +} + diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt new file mode 100644 index 0000000000..5cb0cf25b4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +enum class RoomPrivacy { + Private, + Public, +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt new file mode 100644 index 0000000000..462dedba00 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import io.element.android.features.createroom.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomPrivacyItem( + val privacy: RoomPrivacy, + val icon: ImageVector, + val title: String, + val description: String, +) + +@Composable +fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> { + return RoomPrivacy.values() + .map { + when (it) { + RoomPrivacy.Private -> RoomPrivacyItem( + privacy = it, + icon = Icons.Outlined.Lock, + title = stringResource(R.string.screen_create_room_private_option_title), + description = stringResource(R.string.screen_create_room_private_option_description), + ) + RoomPrivacy.Public -> RoomPrivacyItem( + privacy = it, + icon = Icons.Outlined.Public, + title = stringResource(R.string.screen_create_room_public_option_title), + description = stringResource(R.string.screen_create_room_public_option_description), + ) + } + } + .toImmutableList() +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt new file mode 100644 index 0000000000..f6f50f67bf --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.Subcomponent +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.SingleIn + +@SingleIn(CreateRoomScope::class) +@MergeSubcomponent(CreateRoomScope::class) +interface CreateRoomComponent : NodeFactoriesBindings { + + @Subcomponent.Builder + interface Builder { + fun build(): CreateRoomComponent + } + + @ContributesTo(SessionScope::class) + interface ParentBindings { + fun createRoomComponentBuilder(): Builder + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt new file mode 100644 index 0000000000..c869536c56 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.di + +abstract class CreateRoomScope private constructor() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt new file mode 100644 index 0000000000..7d8211aea5 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface CreateRoomRootEvents { + data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents + object CancelStartDM : CreateRoomRootEvents +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt new file mode 100644 index 0000000000..597c5d0d01 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(SessionScope::class) +class CreateRoomRootNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: CreateRoomRootPresenter, + private val analyticsService: AnalyticsService, + private val inviteFriendsUseCase: InviteFriendsUseCase, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onCreateNewRoom() + fun onStartChatSuccess(roomId: RoomId) + } + + private val callback = object : Callback { + override fun onCreateNewRoom() { + plugins<Callback>().forEach { it.onCreateNewRoom() } + } + + override fun onStartChatSuccess(roomId: RoomId) { + plugins<Callback>().forEach { it.onStartChatSuccess(roomId) } + } + } + + init { + lifecycle.subscribe( + onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = LocalContext.current as Activity + CreateRoomRootView( + state = state, + modifier = modifier, + onClosePressed = this::navigateUp, + onNewRoomClicked = callback::onCreateNewRoom, + onOpenDM = callback::onStartChatSuccess, + onInviteFriendsClicked = { invitePeople(activity) } + ) + } + + private fun invitePeople(activity: Activity) { + inviteFriendsUseCase.execute(activity) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt new file mode 100644 index 0000000000..20d3309a5f --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.createroom.impl.userlist.SelectionMode +import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.features.createroom.impl.userlist.UserListPresenter +import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class CreateRoomRootPresenter @Inject constructor( + private val presenterFactory: UserListPresenter.Factory, + private val userRepository: UserRepository, + private val userListDataStore: UserListDataStore, + private val matrixClient: MatrixClient, + private val analyticsService: AnalyticsService, + private val buildMeta: BuildMeta, +) : Presenter<CreateRoomRootState> { + + private val presenter by lazy { + presenterFactory.create( + UserListPresenterArgs( + selectionMode = SelectionMode.Single, + ), + userRepository, + userListDataStore, + ) + } + + @Composable + override fun present(): CreateRoomRootState { + val userListState = presenter.present() + + val localCoroutineScope = rememberCoroutineScope() + val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } + + fun handleEvents(event: CreateRoomRootEvents) { + when (event) { + is CreateRoomRootEvents.StartDM -> localCoroutineScope.startDm(event.matrixUser, startDmAction) + CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized + } + } + + return CreateRoomRootState( + applicationName = buildMeta.applicationName, + userListState = userListState, + startDmAction = startDmAction.value, + eventSink = ::handleEvents, + ) + } + + private fun CoroutineScope.startDm(matrixUser: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch { + suspend { + matrixClient.findDM(matrixUser.userId).use { existingDM -> + existingDM?.roomId ?: createDM(matrixUser) + } + }.runCatchingUpdatingState(startDmAction) + } + + private suspend fun createDM(user: MatrixUser): RoomId { + return matrixClient + .createDM(user.userId) + .onSuccess { + analyticsService.capture(CreatedRoom(isDM = true)) + } + .getOrThrow() + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt new file mode 100644 index 0000000000..02f64a6c86 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import io.element.android.features.createroom.impl.userlist.UserListState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId + +data class CreateRoomRootState( + val applicationName: String, + val userListState: UserListState, + val startDmAction: Async<RoomId>, + val eventSink: (CreateRoomRootEvents) -> Unit, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt new file mode 100644 index 0000000000..d1484b7a4f --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.createroom.impl.userlist.aUserListState + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.persistentListOf + +open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> { + override val values: Sequence<CreateRoomRootState> + get() = sequenceOf( + aCreateRoomRootState(), + aCreateRoomRootState().copy( + startDmAction = Async.Loading(), + userListState = aMatrixUser().let { + aUserListState().copy( + searchQuery = it.userId.value, + searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))), + selectedUsers = persistentListOf(it), + isSearchActive = true, + ) + } + ), + aCreateRoomRootState().copy( + startDmAction = Async.Failure(Throwable()), + userListState = aMatrixUser().let { + aUserListState().copy( + searchQuery = it.userId.value, + searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))), + selectedUsers = persistentListOf(it), + isSearchActive = true, + ) + } + ), + ) +} + +fun aCreateRoomRootState() = CreateRoomRootState( + eventSink = {}, + applicationName = "Element X Preview", + startDmAction = Async.Uninitialized, + userListState = aUserListState(), +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt new file mode 100644 index 0000000000..ac8a05f448 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.R +import io.element.android.features.createroom.impl.components.UserListView +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.designsystem.R as DrawableR + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CreateRoomRootView( + state: CreateRoomRootState, + modifier: Modifier = Modifier, + onClosePressed: () -> Unit = {}, + onNewRoomClicked: () -> Unit = {}, + onOpenDM: (RoomId) -> Unit = {}, + onInviteFriendsClicked: () -> Unit = {}, +) { + if (state.startDmAction is Async.Success) { + LaunchedEffect(state.startDmAction) { + onOpenDM(state.startDmAction.data) + } + } + + Scaffold( + modifier = modifier.fillMaxWidth(), + topBar = { + if (!state.userListState.isSearchActive) { + CreateRoomRootViewTopBar(onClosePressed = onClosePressed) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserListView( + modifier = Modifier.fillMaxWidth(), + state = state.userListState, + onUserSelected = { + state.eventSink(CreateRoomRootEvents.StartDM(it)) + }, + ) + + if (!state.userListState.isSearchActive) { + CreateRoomActionButtonsList( + state = state, + onNewRoomClicked = onNewRoomClicked, + onInvitePeopleClicked = onInviteFriendsClicked, + ) + } + } + } + + when (state.startDmAction) { + is Async.Loading -> { + ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat)) + } + + is Async.Failure -> { + RetryDialog( + content = stringResource(id = R.string.screen_start_chat_error_starting_chat), + onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, + onRetry = { + state.userListState.selectedUsers.firstOrNull() + ?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) } + // Cancel start DM if there is no more selected user (should not happen) + ?: state.eventSink(CreateRoomRootEvents.CancelStartDM) + }, + ) + } + + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateRoomRootViewTopBar( + modifier: Modifier = Modifier, + onClosePressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(id = CommonStrings.action_start_chat), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { + IconButton(onClick = onClosePressed) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = CommonStrings.action_close), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + ) +} + +@Composable +fun CreateRoomActionButtonsList( + state: CreateRoomRootState, + modifier: Modifier = Modifier, + onNewRoomClicked: () -> Unit = {}, + onInvitePeopleClicked: () -> Unit = {}, +) { + Column(modifier = modifier) { + CreateRoomActionButton( + iconRes = DrawableR.drawable.ic_groups, + text = stringResource(id = R.string.screen_create_room_action_create_room), + onClick = onNewRoomClicked, + ) + CreateRoomActionButton( + iconRes = DrawableR.drawable.ic_share, + text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), + onClick = onInvitePeopleClicked, + ) + } +} + +@Composable +fun CreateRoomActionButton( + @DrawableRes iconRes: Int, + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clickable { onClick() } + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.secondary, + resourceId = iconRes, + contentDescription = null, + ) + Text( + text = text, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } +} + +@Preview +@Composable +fun CreateRoomRootViewLightPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun CreateRoomRootViewDarkPreview(@PreviewParameter(CreateRoomRootStateProvider::class) state: CreateRoomRootState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: CreateRoomRootState) { + CreateRoomRootView( + state = state, + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt new file mode 100644 index 0000000000..867fdc9a60 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class DefaultUserListPresenter @AssistedInject constructor( + @Assisted val args: UserListPresenterArgs, + @Assisted val userRepository: UserRepository, + @Assisted val userListDataStore: UserListDataStore, +) : UserListPresenter { + + @AssistedFactory + @ContributesBinding(SessionScope::class) + interface DefaultUserListFactory : UserListPresenter.Factory { + override fun create( + args: UserListPresenterArgs, + userRepository: UserRepository, + userListDataStore: UserListDataStore, + ): DefaultUserListPresenter + } + + @Composable + override fun present(): UserListState { + var isSearchActive by rememberSaveable { mutableStateOf(false) } + val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember { + mutableStateOf(SearchBarResultState.NotSearching()) + } + + LaunchedEffect(searchQuery) { + searchResults = SearchBarResultState.NotSearching() + + userRepository.search(searchQuery).collect { + searchResults = when { + it.isEmpty() -> SearchBarResultState.NoResults() + else -> SearchBarResultState.Results(it.toImmutableList()) + } + } + } + + return UserListState( + searchQuery = searchQuery, + searchResults = searchResults, + selectedUsers = selectedUsers.toImmutableList(), + isSearchActive = isSearchActive, + selectionMode = args.selectionMode, + eventSink = { event -> + when (event) { + is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active + is UserListEvents.UpdateSearchQuery -> searchQuery = event.query + is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser) + is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser) + } + }, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt new file mode 100644 index 0000000000..8de7be3114 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListDataStore.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +class UserListDataStore @Inject constructor() { + + private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList()) + + fun selectUser(user: MatrixUser) { + if (user !in selectedUsers.value) { + selectedUsers.tryEmit(selectedUsers.value.plus(user)) + } + } + + fun removeUserFromSelection(user: MatrixUser) { + selectedUsers.tryEmit(selectedUsers.value.minus(user)) + } + + fun selectedUsers(): Flow<List<MatrixUser>> = selectedUsers +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt new file mode 100644 index 0000000000..6dc817d22c --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface UserListEvents { + data class UpdateSearchQuery(val query: String) : UserListEvents + data class AddToSelection(val matrixUser: MatrixUser) : UserListEvents + data class RemoveFromSelection(val matrixUser: MatrixUser) : UserListEvents + data class OnSearchActiveChanged(val active: Boolean) : UserListEvents +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt new file mode 100644 index 0000000000..e5d68a2e28 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenter.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.usersearch.api.UserRepository + +interface UserListPresenter : Presenter<UserListState> { + + interface Factory { + fun create( + args: UserListPresenterArgs, + userRepository: UserRepository, + userListDataStore: UserListDataStore, + ): UserListPresenter + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt new file mode 100644 index 0000000000..15c2a94a24 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListPresenterArgs.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +data class UserListPresenterArgs( + val selectionMode: SelectionMode, +) + +enum class SelectionMode { + Single, + Multiple, +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt new file mode 100644 index 0000000000..60a5bea506 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.ImmutableList + +data class UserListState( + val searchQuery: String, + val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>, + val selectedUsers: ImmutableList<MatrixUser>, + val isSearchActive: Boolean, + val selectionMode: SelectionMode, + val eventSink: (UserListEvents) -> Unit, +) { + val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt new file mode 100644 index 0000000000..31d1f6953a --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/userlist/UserListStateProvider.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class UserListStateProvider : PreviewParameterProvider<UserListState> { + override val values: Sequence<UserListState> + get() = sequenceOf( + aUserListState(), + aUserListState().copy( + isSearchActive = false, + selectedUsers = aListOfSelectedUsers(), + selectionMode = SelectionMode.Multiple, + ), + aUserListState().copy(isSearchActive = true), + aUserListState().copy(isSearchActive = true, searchQuery = "someone"), + aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), + aUserListState().copy( + isSearchActive = true, + searchQuery = "@someone:matrix.org", + selectedUsers = aListOfSelectedUsers(), + searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()), + ), + aUserListState().copy( + isSearchActive = true, + searchQuery = "@someone:matrix.org", + selectionMode = SelectionMode.Multiple, + selectedUsers = aListOfSelectedUsers(), + searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()), + ), + aUserListState().copy( + isSearchActive = true, + searchQuery = "something-with-no-results", + searchResults = SearchBarResultState.NoResults() + ), + ) +} + +fun aUserListState() = UserListState( + isSearchActive = false, + searchQuery = "", + searchResults = SearchBarResultState.NotSearching(), + selectedUsers = persistentListOf(), + selectionMode = SelectionMode.Single, + eventSink = {} +) + +fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList() diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..febd535cb0 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Nová místnost"</string> + <string name="screen_create_room_action_invite_people">"Pozvat přátele do Elementu"</string> + <string name="screen_create_room_add_people_title">"Pozvat lidi"</string> + <string name="screen_create_room_error_creating_room">"Při vytváření místnosti došlo k chybě"</string> + <string name="screen_create_room_private_option_description">"Zprávy v této místnosti jsou šifrované. Šifrování nelze později vypnout."</string> + <string name="screen_create_room_private_option_title">"Soukromá místnost (jen pro pozvané)"</string> + <string name="screen_create_room_public_option_description">"Zprávy nejsou šifrované a může si je přečíst kdokoli. Šifrování můžete povolit později."</string> + <string name="screen_create_room_public_option_title">"Veřejná místnost (kdokoli)"</string> + <string name="screen_create_room_room_name_label">"Název místnosti"</string> + <string name="screen_create_room_topic_label">"Téma (nepovinné)"</string> + <string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string> + <string name="screen_create_room_title">"Vytvořit místnost"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..abc2ef9d71 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Neuer Raum"</string> + <string name="screen_create_room_action_invite_people">"Freunde zu Element einladen"</string> + <string name="screen_create_room_add_people_title">"Personen hinzufügen"</string> + <string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string> + <string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string> + <string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string> + <string name="screen_create_room_public_option_description">"Nachrichten sind nicht verschlüsselt und jeder kann sie lesen. Du kannst die Verschlüsselung zu einem späteren Zeitpunkt aktivieren."</string> + <string name="screen_create_room_public_option_title">"Öffentlicher Raum (jeder)"</string> + <string name="screen_create_room_room_name_label">"Raumname"</string> + <string name="screen_create_room_topic_label">"Thema (optional)"</string> + <string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string> + <string name="screen_create_room_title">"Raum erstellen"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..9a5d672fd4 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Nueva sala"</string> + <string name="screen_create_room_action_invite_people">"Invitar gente"</string> + <string name="screen_create_room_add_people_title">"Añadir personas"</string> + <string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string> + <string name="screen_create_room_title">"Crear una sala"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..6be7345c97 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Nouveau salon"</string> + <string name="screen_create_room_action_invite_people">"Inviter des amis sur Element"</string> + <string name="screen_create_room_add_people_title">"Inviter des personnes"</string> + <string name="screen_create_room_error_creating_room">"Une erreur s\'est produite lors de la création du salon"</string> + <string name="screen_create_room_private_option_description">"Les messages dans ce salon sont chiffrés. Une fopis activé, le chiffrement ne peut pas être désactivé."</string> + <string name="screen_create_room_private_option_title">"Salon privé (sur invitation uniquement)"</string> + <string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string> + <string name="screen_create_room_public_option_title">"Salon public (n’importe qui)"</string> + <string name="screen_create_room_room_name_label">"Nom du salon"</string> + <string name="screen_create_room_topic_label">"Sujet (optionnel)"</string> + <string name="screen_start_chat_error_starting_chat">"Une erreur s\'est produite lors de la tentative de démarrage d\'une discussion"</string> + <string name="screen_create_room_title">"Créer un salon"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..ceddb71154 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Nuova stanza"</string> + <string name="screen_create_room_action_invite_people">"Invita persone"</string> + <string name="screen_create_room_add_people_title">"Aggiungi persone"</string> + <string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string> + <string name="screen_create_room_title">"Crea una stanza"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..9f68a006e5 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Cameră nouă"</string> + <string name="screen_create_room_action_invite_people">"Invitați prieteni în Element"</string> + <string name="screen_create_room_add_people_title">"Invitați persoane"</string> + <string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string> + <string name="screen_create_room_private_option_description">"Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior."</string> + <string name="screen_create_room_private_option_title">"Cameră privată (doar pe bază de invitație)"</string> + <string name="screen_create_room_public_option_description">"Mesajele nu sunt criptate și oricine le poate citi. Puteți activa criptarea la o dată ulterioară."</string> + <string name="screen_create_room_public_option_title">"Cameră publică (oricine)"</string> + <string name="screen_create_room_room_name_label">"Numele camerei"</string> + <string name="screen_create_room_topic_label">"Subiect (opțional)"</string> + <string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string> + <string name="screen_create_room_title">"Creați o cameră"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..831ac67369 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"Nová miestnosť"</string> + <string name="screen_create_room_action_invite_people">"Pozvať priateľov na Element"</string> + <string name="screen_create_room_add_people_title">"Pozvať ľudí"</string> + <string name="screen_create_room_error_creating_room">"Pri vytváraní miestnosti došlo k chybe"</string> + <string name="screen_create_room_private_option_description">"Správy v tejto miestnosti sú šifrované. Šifrovanie už potom nie je možné vypnúť."</string> + <string name="screen_create_room_private_option_title">"Súkromná miestnosť (len pre pozvaných)"</string> + <string name="screen_create_room_public_option_description">"Správy nie sú šifrované a môže si ich prečítať ktokoľvek. Šifrovanie môžete zapnúť neskôr."</string> + <string name="screen_create_room_public_option_title">"Verejná miestnosť (ktokoľvek)"</string> + <string name="screen_create_room_room_name_label">"Názov miestnosti"</string> + <string name="screen_create_room_topic_label">"Téma (voliteľné)"</string> + <string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string> + <string name="screen_create_room_title">"Vytvoriť miestnosť"</string> +</resources> diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..68f318d385 --- /dev/null +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_create_room_action_create_room">"New room"</string> + <string name="screen_create_room_action_invite_people">"Invite friends to Element"</string> + <string name="screen_create_room_add_people_title">"Invite people"</string> + <string name="screen_create_room_error_creating_room">"An error occurred when creating the room"</string> + <string name="screen_create_room_private_option_description">"Messages in this room are encrypted. Encryption can’t be disabled afterwards."</string> + <string name="screen_create_room_private_option_title">"Private room (invite only)"</string> + <string name="screen_create_room_public_option_description">"Messages are not encrypted and anyone can read them. You can enable encryption at a later date."</string> + <string name="screen_create_room_public_option_title">"Public room (anyone)"</string> + <string name="screen_create_room_room_name_label">"Room name"</string> + <string name="screen_create_room_topic_label">"Topic (optional)"</string> + <string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string> + <string name="screen_create_room_title">"Create a room"</string> +</resources> diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt new file mode 100644 index 0000000000..fe8c7b7462 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.addpeople + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory +import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.libraries.usersearch.test.FakeUserRepository +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class AddPeoplePresenterTests { + + private lateinit var presenter: AddPeoplePresenter + + @Before + fun setup() { + presenter = AddPeoplePresenter( + FakeUserListPresenterFactory(), + FakeUserRepository(), + CreateRoomDataStore(UserListDataStore()) + ) + } + + @Test + fun `present - initial state`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // TODO This doesn't actually test anything... + val initialState = awaitItem() + assertThat(initialState) + } + } +} + diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt new file mode 100644 index 0000000000..9b6ac2e067 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +private const val AN_URI_FROM_CAMERA = "content://uri_from_camera" +private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" + +@RunWith(RobolectricTestRunner::class) +class ConfigureRoomPresenterTests { + + private lateinit var presenter: ConfigureRoomPresenter + private lateinit var userListDataStore: UserListDataStore + private lateinit var createRoomDataStore: CreateRoomDataStore + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var fakePickerProvider: FakePickerProvider + private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor + private lateinit var fakeAnalyticsService: FakeAnalyticsService + + @Before + fun setup() { + fakeMatrixClient = FakeMatrixClient() + userListDataStore = UserListDataStore() + createRoomDataStore = CreateRoomDataStore(userListDataStore) + fakePickerProvider = FakePickerProvider() + fakeMediaPreProcessor = FakeMediaPreProcessor() + fakeAnalyticsService = FakeAnalyticsService() + presenter = ConfigureRoomPresenter( + dataStore = createRoomDataStore, + matrixClient = fakeMatrixClient, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, + analyticsService = fakeAnalyticsService, + ) + + mockkStatic(File::readBytes) + every { any<File>().readBytes() } returns byteArrayOf() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `present - initial state`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.config).isEqualTo(CreateRoomConfig()) + assertThat(initialState.config.roomName).isNull() + assertThat(initialState.config.topic).isNull() + assertThat(initialState.config.invites).isEmpty() + assertThat(initialState.config.avatarUri).isNull() + assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private) + } + } + + @Test + fun `present - create room button is enabled only if the required fields are completed`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + var config = initialState.config + assertThat(initialState.isCreateButtonEnabled).isFalse() + + // Room name not empty + initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) + var newState: ConfigureRoomState = awaitItem() + config = config.copy(roomName = A_ROOM_NAME) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isCreateButtonEnabled).isTrue() + + // Clear room name + newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) + newState = awaitItem() + config = config.copy(roomName = null) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isCreateButtonEnabled).isFalse() + } + } + + @Test + fun `present - state is updated when fields are changed`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + var expectedConfig = CreateRoomConfig() + assertThat(initialState.config).isEqualTo(expectedConfig) + + // Select User + val selectedUser1 = aMatrixUser() + val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob") + userListDataStore.selectUser(selectedUser1) + skipItems(1) + userListDataStore.selectUser(selectedUser2) + var newState = awaitItem() + expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2)) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room name + initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room topic + newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(topic = A_MESSAGE) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room avatar + // Pick avatar + fakePickerProvider.givenResult(null) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + // From gallery + val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) + fakePickerProvider.givenResult(uriFromGallery) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) + assertThat(newState.config).isEqualTo(expectedConfig) + // From camera + val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) + fakePickerProvider.givenResult(uriFromCamera) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) + assertThat(newState.config).isEqualTo(expectedConfig) + // Remove + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = null) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room privacy + newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Remove user + newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList()) + assertThat(newState.config).isEqualTo(expectedConfig) + } + } + + @Test + fun `present - trigger create room action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) + + fakeMatrixClient.givenCreateRoomResult(createRoomResult) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull()) + } + } + + @Test + fun `present - record analytics when creating room`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) + + fakeMatrixClient.givenCreateRoomResult(createRoomResult) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + skipItems(2) + + val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull() + assertThat(analyticsEvent).isNotNull() + assertThat(analyticsEvent?.isDM).isFalse() + } + } + + @Test + fun `present - trigger create room with upload error and retry`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) + fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) + fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE)) + + val initialState = awaitItem() + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() + + fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java) + } + } + + @Test + fun `present - trigger retry and cancel actions`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val createRoomResult = Result.failure<RoomId>(A_THROWABLE) + + fakeMatrixClient.givenCreateRoomResult(createRoomResult) + + // Create + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) + assertThat((stateAfterCreateRoom.createRoomAction as? Async.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) + + // Retry + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + val stateAfterRetry = awaitItem() + assertThat(stateAfterRetry.createRoomAction).isInstanceOf(Async.Failure::class.java) + assertThat((stateAfterRetry.createRoomAction as? Async.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) + + // Cancel + stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) + } + } +} + diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt new file mode 100644 index 0000000000..8976c77b8e --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.root + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter +import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory +import io.element.android.features.createroom.impl.userlist.UserListDataStore +import io.element.android.features.createroom.impl.userlist.aUserListState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.usersearch.test.FakeUserRepository +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class CreateRoomRootPresenterTests { + + private lateinit var userRepository: FakeUserRepository + private lateinit var presenter: CreateRoomRootPresenter + private lateinit var fakeUserListPresenter: FakeUserListPresenter + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var fakeAnalyticsService: FakeAnalyticsService + + @Before + fun setup() { + fakeUserListPresenter = FakeUserListPresenter() + fakeMatrixClient = FakeMatrixClient() + fakeAnalyticsService = FakeAnalyticsService() + userRepository = FakeUserRepository() + presenter = CreateRoomRootPresenter( + presenterFactory = FakeUserListPresenterFactory(fakeUserListPresenter), + userRepository = userRepository, + userListDataStore = UserListDataStore(), + matrixClient = fakeMatrixClient, + analyticsService = fakeAnalyticsService, + buildMeta = aBuildMeta(), + ) + } + + @Test + fun `present - initial state`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(Async.Uninitialized::class.java) + assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) + assertThat(initialState.userListState.selectedUsers).isEmpty() + assertThat(initialState.userListState.isSearchActive).isFalse() + assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() + } + } + + @Test + fun `present - trigger create DM action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:domain")) + val createDmResult = Result.success(RoomId("!createDmResult:domain")) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmResult(createDmResult) + + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) + } + } + + @Test + fun `present - creating a DM records analytics event`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:domain")) + val createDmResult = Result.success(RoomId("!createDmResult:domain")) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmResult(createDmResult) + + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + skipItems(2) + + val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull() + assertThat(analyticsEvent).isNotNull() + assertThat(analyticsEvent?.isDM).isTrue() + } + } + + @Test + fun `present - trigger retrieve DM action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:domain")) + val fakeDmResult = FakeMatrixRoom(roomId = RoomId("!fakeDmResult:domain")) + + fakeMatrixClient.givenFindDmResult(fakeDmResult) + + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() + } + } + + @Test + fun `present - trigger retry create DM action`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val matrixUser = MatrixUser(UserId("@name:domain")) + val createDmResult = Result.success(RoomId("!createDmResult:domain")) + fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser))) + + fakeMatrixClient.givenFindDmResult(null) + fakeMatrixClient.givenCreateDmError(A_THROWABLE) + fakeMatrixClient.givenCreateDmResult(createDmResult) + + // Failure + initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterStartDM = awaitItem() + assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() + + // Cancel + stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM) + val stateAfterCancel = awaitItem() + assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java) + + // Failure + stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterSecondAttempt = awaitItem() + assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java) + assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() + + // Retry with success + fakeMatrixClient.givenCreateDmError(null) + stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) + assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java) + val stateAfterRetryStartDM = awaitItem() + assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java) + assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull()) + } + } +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt new file mode 100644 index 0000000000..745bdf74f9 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.usersearch.api.UserSearchResult +import io.element.android.libraries.usersearch.test.FakeUserRepository +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUserListPresenterTests { + + private val userRepository = FakeUserRepository() + + @Test + fun `present - initial state for single selection`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.searchQuery).isEmpty() + assertThat(initialState.isMultiSelectionEnabled).isFalse() + assertThat(initialState.isSearchActive).isFalse() + assertThat(initialState.selectedUsers).isEmpty() + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + } + } + + @Test + fun `present - initial state for multiple selection`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Multiple), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.searchQuery).isEmpty() + assertThat(initialState.isMultiSelectionEnabled).isTrue() + assertThat(initialState.isSearchActive).isFalse() + assertThat(initialState.selectedUsers).isEmpty() + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + } + } + + @Test + fun `present - update search query`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink(UserListEvents.OnSearchActiveChanged(true)) + assertThat(awaitItem().isSearchActive).isTrue() + + val matrixIdQuery = "@name:matrix.org" + initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery)) + assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery) + assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery) + skipItems(1) + + val notMatrixIdQuery = "name" + initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery)) + assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery) + assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery) + skipItems(1) + + initialState.eventSink(UserListEvents.OnSearchActiveChanged(false)) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - presents search results`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs( + selectionMode = SelectionMode.Single, + ), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(userRepository.providedQuery).isEqualTo("alice") + skipItems(2) + + // When the user repository emits a result, it's copied to the state + userRepository.emitResult(listOf(UserSearchResult(aMatrixUser()))) + assertThat(awaitItem().searchResults).isEqualTo( + SearchBarResultState.Results( + persistentListOf(UserSearchResult(aMatrixUser())) + ) + ) + + // When the user repository emits another result, it replaces the previous value + userRepository.emitResult(aMatrixUserList().map { UserSearchResult(it) }) + assertThat(awaitItem().searchResults).isEqualTo( + SearchBarResultState.Results( + aMatrixUserList().map { UserSearchResult(it) } + ) + ) + } + } + + @Test + fun `present - presents search results when not found`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs( + selectionMode = SelectionMode.Single, + ), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(userRepository.providedQuery).isEqualTo("alice") + skipItems(2) + + // When the results list is empty, the state is set to NoResults + userRepository.emitResult(emptyList()) + assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - select a user`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + val userA = aMatrixUser("@userA:domain", "A") + val userB = aMatrixUser("@userB:domain", "B") + val userABis = aMatrixUser("@userA:domain", "A") + val userC = aMatrixUser("@userC:domain", "C") + + initialState.eventSink(UserListEvents.AddToSelection(userA)) + assertThat(awaitItem().selectedUsers).containsExactly(userA) + + initialState.eventSink(UserListEvents.AddToSelection(userB)) + assertThat(awaitItem().selectedUsers).containsExactly(userA, userB) + + initialState.eventSink(UserListEvents.AddToSelection(userABis)) + initialState.eventSink(UserListEvents.AddToSelection(userC)) + // duplicated users should be ignored + assertThat(awaitItem().selectedUsers).containsExactly(userA, userB, userC) + + initialState.eventSink(UserListEvents.RemoveFromSelection(userB)) + assertThat(awaitItem().selectedUsers).containsExactly(userA, userC) + initialState.eventSink(UserListEvents.RemoveFromSelection(userA)) + assertThat(awaitItem().selectedUsers).containsExactly(userC) + initialState.eventSink(UserListEvents.RemoveFromSelection(userC)) + assertThat(awaitItem().selectedUsers).isEmpty() + } + } +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt new file mode 100644 index 0000000000..45eb712dc1 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import androidx.compose.runtime.Composable + +class FakeUserListPresenter : UserListPresenter { + + private var state = aUserListState() + + fun givenState(state: UserListState) { + this.state = state + } + + @Composable + override fun present(): UserListState { + return state + } +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt new file mode 100644 index 0000000000..07697ce458 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/FakeUserListPresenterFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.createroom.impl.userlist + +import io.element.android.libraries.usersearch.api.UserRepository + +class FakeUserListPresenterFactory( + private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter() +) : UserListPresenter.Factory { + + override fun create( + args: UserListPresenterArgs, + userRepository: UserRepository, + userListDataStore: UserListDataStore, + ): UserListPresenter = fakeUserListPresenter +} diff --git a/features/ftue/api/build.gradle.kts b/features/ftue/api/build.gradle.kts new file mode 100644 index 0000000000..9fd36026b9 --- /dev/null +++ b/features/ftue/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.ftue.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt new file mode 100644 index 0000000000..649a327f6e --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface FtueEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onFtueFlowFinished() + } +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt new file mode 100644 index 0000000000..cd172669cc --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.api.state + +import kotlinx.coroutines.flow.StateFlow + +interface FtueState { + val shouldDisplayFlow: StateFlow<Boolean> + + suspend fun reset() +} diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts new file mode 100644 index 0000000000..0dee792464 --- /dev/null +++ b/features/ftue/impl/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.ftue.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.ftue.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(projects.features.analytics.api) + implementation(projects.services.analytics.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.analytics.test) + + ksp(libs.showkase.processor) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt new file mode 100644 index 0000000000..9c2f74f072 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder { + val plugins = ArrayList<Plugin>() + + return object : FtueEntryPoint.NodeBuilder { + + override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<FtueFlowNode>(buildContext, plugins) + } + } + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt new file mode 100644 index 0000000000..0ff9c80d46 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.replace +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.analytics.api.AnalyticsEntryPoint +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.impl.state.DefaultFtueState +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.welcome.WelcomeNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +class FtueFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val ftueState: DefaultFtueState, + private val analyticsEntryPoint: AnalyticsEntryPoint, + private val analyticsService: AnalyticsService, +) : BackstackNode<FtueFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Placeholder, + savedStateMap = buildContext.savedStateMap, + backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(), + ), + buildContext = buildContext, + plugins = plugins, +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Placeholder : NavTarget + + @Parcelize + object WelcomeScreen : NavTarget + + @Parcelize + object AnalyticsOptIn : NavTarget + } + + private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull() + + override fun onBuilt() { + super.onBuilt() + + lifecycle.subscribe(onCreate = { + lifecycleScope.launch { moveToNextStep() } + }) + + analyticsService.didAskUserConsent() + .drop(1) // We only care about consent passing from not asked to asked state + .onEach { didAskUserConsent -> + if (didAskUserConsent) { + lifecycleScope.launch { moveToNextStep() } + } + } + .launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Placeholder -> { + createNode<PlaceholderNode>(buildContext) + } + NavTarget.WelcomeScreen -> { + val callback = object : WelcomeNode.Callback { + override fun onContinueClicked() { + ftueState.setWelcomeScreenShown() + lifecycleScope.launch { moveToNextStep() } + } + } + createNode<WelcomeNode>(buildContext, listOf(callback)) + } + NavTarget.AnalyticsOptIn -> { + analyticsEntryPoint.createNode(this, buildContext) + } + } + } + + private suspend fun moveToNextStep() { + when (ftueState.getNextStep()) { + is FtueStep.WelcomeScreen -> { + backstack.newRoot(NavTarget.WelcomeScreen) + } + is FtueStep.AnalyticsOptIn -> { + backstack.replace(NavTarget.AnalyticsOptIn) + } + null -> callback?.onFtueFlowFinished() + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } + + @ContributesNode(AppScope::class) + class PlaceholderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + ) : Node(buildContext, plugins = plugins) +} + +private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() { + override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true) + + override fun onBackPressed() { + // No-op + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt new file mode 100644 index 0000000000..52c8d90254 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.state + +import androidx.annotation.VisibleForTesting +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState +import io.element.android.libraries.di.AppScope +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultFtueState @Inject constructor( + private val coroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, + private val welcomeScreenState: WelcomeScreenState, +) : FtueState { + + override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) + + override suspend fun reset() { + welcomeScreenState.reset() + analyticsService.reset() + } + + init { + analyticsService.didAskUserConsent() + .onEach { updateState() } + .launchIn(coroutineScope) + } + + fun getNextStep(currentStep: FtueStep? = null): FtueStep? = + when (currentStep) { + null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( + FtueStep.WelcomeScreen + ) + FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep( + FtueStep.AnalyticsOptIn + ) + FtueStep.AnalyticsOptIn -> null + } + + private fun isAnyStepIncomplete(): Boolean { + return listOf( + shouldDisplayWelcomeScreen(), + needsAnalyticsOptIn() + ).any { it } + } + + private fun needsAnalyticsOptIn(): Boolean { + // We need this function to not be suspend, so we need to load the value through runBlocking + return runBlocking { analyticsService.didAskUserConsent().first().not() } + } + + private fun shouldDisplayWelcomeScreen(): Boolean { + return welcomeScreenState.isWelcomeScreenNeeded() + } + + fun setWelcomeScreenShown() { + welcomeScreenState.setWelcomeScreenShown() + updateState() + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun updateState() { + shouldDisplayFlow.value = isAnyStepIncomplete() + } +} + +sealed interface FtueStep { + object WelcomeScreen : FtueStep + object AnalyticsOptIn : FtueStep +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt new file mode 100644 index 0000000000..f4e0d9f640 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.welcome + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class WelcomeNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val buildMeta: BuildMeta, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onContinueClicked() + } + + private fun onContinueClicked() { + plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + WelcomeView( + applicationName = buildMeta.applicationName, + onContinueClicked = ::onContinueClicked, + modifier = modifier + ) + } + +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt new file mode 100644 index 0000000000..7397e5ecc5 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.welcome + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddComment +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.features.ftue.impl.R +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem +import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun WelcomeView( + applicationName: String, + modifier: Modifier = Modifier, + onContinueClicked: () -> Unit, +) { + BackHandler(onBack = onContinueClicked) + OnBoardingPage( + modifier = modifier + .systemBarsPadding() + .fillMaxSize(), + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(78.dp)) + ElementLogoAtom(size = ElementLogoAtomSize.Medium) + Spacer(modifier = Modifier.height(32.dp)) + Text( + modifier = Modifier.testTag(TestTags.welcomeScreenTitle), + text = stringResource(R.string.screen_welcome_title, applicationName), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_welcome_subtitle), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + InfoListOrganism( + items = listItems(), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.iconSecondary, + backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f), + ) + Spacer(modifier = Modifier.height(32.dp)) + } + }, + footer = { + Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) { + Text(text = stringResource(CommonStrings.action_continue)) + } + Spacer(modifier = Modifier.height(32.dp)) + } + ) +} + +@Composable +private fun listItems() = persistentListOf( + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_1), + iconVector = Icons.Outlined.NewReleases, + ), + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_2), + iconVector = Icons.Outlined.Lock, + ), + InfoListItem( + message = stringResource(R.string.screen_welcome_bullet_3), + iconVector = Icons.Outlined.AddComment, + ), +) + +@DayNightPreviews +@Composable +internal fun WelcomeViewPreview() { + ElementPreview { + WelcomeView(applicationName = "Element X", onContinueClicked = {}) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt new file mode 100644 index 0000000000..6dbef47285 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.welcome.state + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class AndroidWelcomeScreenState @Inject constructor( + @DefaultPreferences private val sharedPreferences: SharedPreferences, +) : WelcomeScreenState { + + companion object { + private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown" + } + + override fun isWelcomeScreenNeeded(): Boolean { + return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not() + } + + override fun setWelcomeScreenShown() { + sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply() + } + + override fun reset() { + sharedPreferences.edit { + remove(IS_WELCOME_SCREEN_SHOWN) + } + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt new file mode 100644 index 0000000000..d2be17fcbb --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.welcome.state + +interface WelcomeScreenState { + fun isWelcomeScreenNeeded(): Boolean + fun setWelcomeScreenShown() + fun reset() +} diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..17999e7158 --- /dev/null +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string> + <string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string> + <string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string> + <string name="screen_welcome_button">"Let\'s go!"</string> + <string name="screen_welcome_subtitle">"Here’s what you need to know:"</string> + <string name="screen_welcome_title">"Welcome to %1$s!"</string> +</resources> diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt new file mode 100644 index 0000000000..ce1683e8e5 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.ftue.impl.state.DefaultFtueState +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFtueStateTests { + + @Test + fun `given any check being false, should display flow is true`() = runTest { + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + val state = createState(coroutineScope) + + assertThat(state.shouldDisplayFlow.value).isTrue() + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `given all checks being true, should display flow is false`() = runTest { + val welcomeState = FakeWelcomeState() + val analyticsService = FakeAnalyticsService() + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + + val state = createState(coroutineScope, welcomeState, analyticsService) + + welcomeState.setWelcomeScreenShown() + analyticsService.setDidAskUserConsent() + state.updateState() + + assertThat(state.shouldDisplayFlow.value).isFalse() + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `traverse flow`() = runTest { + val welcomeState = FakeWelcomeState() + val analyticsService = FakeAnalyticsService() + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + + val state = createState(coroutineScope, welcomeState, analyticsService) + val steps = mutableListOf<FtueStep?>() + + // First step, welcome screen + steps.add(state.getNextStep(steps.lastOrNull())) + welcomeState.setWelcomeScreenShown() + + // Second step, analytics opt in + steps.add(state.getNextStep(steps.lastOrNull())) + analyticsService.setDidAskUserConsent() + + // Final step (null) + steps.add(state.getNextStep(steps.lastOrNull())) + + assertThat(steps).containsExactly( + FtueStep.WelcomeScreen, + FtueStep.AnalyticsOptIn, + null, // Final state + ) + + // Cleanup + coroutineScope.cancel() + } + + @Test + fun `if a check for a step is true, start from the next one`() = runTest { + val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) + val analyticsService = FakeAnalyticsService() + val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService) + + state.setWelcomeScreenShown() + assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) + + analyticsService.setDidAskUserConsent() + assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull() + + // Cleanup + coroutineScope.cancel() + } + + private fun createState( + coroutineScope: CoroutineScope, + welcomeState: FakeWelcomeState = FakeWelcomeState(), + analyticsService: AnalyticsService = FakeAnalyticsService() + ) = DefaultFtueState(coroutineScope, analyticsService, welcomeState) + +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt new file mode 100644 index 0000000000..e38d49db1c --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.ftue.impl.welcome.state + +class FakeWelcomeState : WelcomeScreenState { + + private var isWelcomeScreenNeeded = true + + override fun isWelcomeScreenNeeded(): Boolean { + return isWelcomeScreenNeeded + } + + override fun setWelcomeScreenShown() { + isWelcomeScreenNeeded = false + } + + override fun reset() { + isWelcomeScreenNeeded = true + } +} diff --git a/features/invitelist/api/build.gradle.kts b/features/invitelist/api/build.gradle.kts new file mode 100644 index 0000000000..6ea2b8a49d --- /dev/null +++ b/features/invitelist/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.invitelist.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt new file mode 100644 index 0000000000..790aac39be --- /dev/null +++ b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/InviteListEntryPoint.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId + +interface InviteListEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onBackClicked() + + fun onInviteAccepted(roomId: RoomId) + } +} + diff --git a/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt new file mode 100644 index 0000000000..ac143b8740 --- /dev/null +++ b/features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.api + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow + +interface SeenInvitesStore { + fun seenRoomIds(): Flow<Set<RoomId>> + suspend fun markAsSeen(roomIds: Set<RoomId>) +} diff --git a/features/invitelist/impl/build.gradle.kts b/features/invitelist/impl/build.gradle.kts new file mode 100644 index 0000000000..3f8f1a44ed --- /dev/null +++ b/features/invitelist/impl/build.gradle.kts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.invitelist.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.invitelist.api) + implementation(libs.androidx.datastore.preferences) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.api) + implementation(projects.libraries.push.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.features.invitelist.test) + testImplementation(projects.features.analytics.test) + + ksp(libs.showkase.processor) +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt new file mode 100644 index 0000000000..ef98bc0019 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultInviteListEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.invitelist.api.InviteListEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder { + val plugins = ArrayList<Plugin>() + + return object : InviteListEntryPoint.NodeBuilder { + + override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<InviteListNode>(buildContext, plugins) + } + } + } +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt new file mode 100644 index 0000000000..848a4e2ba7 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_seeninvites") +private val seenInvitesKey = stringSetPreferencesKey("seenInvites") + + +@ContributesBinding(SessionScope::class) +class DefaultSeenInvitesStore @Inject constructor( + @ApplicationContext context: Context +) : SeenInvitesStore { + + private val store = context.dataStore + + override fun seenRoomIds(): Flow<Set<RoomId>> = + store.data.map { prefs -> + prefs[seenInvitesKey] + .orEmpty() + .map { RoomId(it) } + .toSet() + } + + override suspend fun markAsSeen(roomIds: Set<RoomId>) { + store.edit { prefs -> + prefs[seenInvitesKey] = roomIds.map { it.value }.toSet() + } + } + +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt new file mode 100644 index 0000000000..0b8f03b45a --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListEvents.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import io.element.android.features.invitelist.impl.model.InviteListInviteSummary + +sealed interface InviteListEvents { + + data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents + data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents + + object ConfirmDeclineInvite: InviteListEvents + object CancelDeclineInvite: InviteListEvents + + object DismissAcceptError: InviteListEvents + object DismissDeclineError: InviteListEvents + +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt new file mode 100644 index 0000000000..2f67f83994 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.invitelist.api.InviteListEntryPoint +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesNode(SessionScope::class) +class InviteListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: InviteListPresenter, +) : Node(buildContext, plugins = plugins) { + + private fun onBackClicked() { + plugins<InviteListEntryPoint.Callback>().forEach { it.onBackClicked() } + } + + private fun onInviteAccepted(roomId: RoomId) { + plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + InviteListView( + state = state, + onBackClicked = ::onBackClicked, + onInviteAccepted = ::onInviteAccepted, + ) + } +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt new file mode 100644 index 0000000000..21a57b48a7 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.invitelist.impl.model.InviteListInviteSummary +import io.element.android.features.invitelist.impl.model.InviteSender +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +class InviteListPresenter @Inject constructor( + private val client: MatrixClient, + private val store: SeenInvitesStore, + private val analyticsService: AnalyticsService, + private val notificationDrawerManager: NotificationDrawerManager, +) : Presenter<InviteListState> { + + @Composable + override fun present(): InviteListState { + val invites by client + .roomSummaryDataSource + .inviteRooms() + .collectAsState() + + var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) } + + LaunchedEffect(Unit) { + seenInvites = store.seenRoomIds().first() + } + + LaunchedEffect(invites) { + store.markAsSeen( + invites + .filterIsInstance<RoomSummary.Filled>() + .map { it.details.roomId } + .toSet() + ) + } + + val localCoroutineScope = rememberCoroutineScope() + val acceptedAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } + val declinedAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } + val decliningInvite: MutableState<InviteListInviteSummary?> = remember { mutableStateOf(null) } + + fun handleEvent(event: InviteListEvents) { + when (event) { + is InviteListEvents.AcceptInvite -> { + acceptedAction.value = Async.Uninitialized + localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + } + + is InviteListEvents.DeclineInvite -> { + decliningInvite.value = event.invite + } + + is InviteListEvents.ConfirmDeclineInvite -> { + declinedAction.value = Async.Uninitialized + decliningInvite.value?.let { + localCoroutineScope.declineInvite(it.roomId, declinedAction) + } + decliningInvite.value = null + } + + is InviteListEvents.CancelDeclineInvite -> { + decliningInvite.value = null + } + + is InviteListEvents.DismissAcceptError -> { + acceptedAction.value = Async.Uninitialized + } + + is InviteListEvents.DismissDeclineError -> { + declinedAction.value = Async.Uninitialized + } + } + } + + val inviteList = remember(seenInvites, invites) { + invites + .filterIsInstance<RoomSummary.Filled>() + .map { + it.toInviteSummary(seenInvites.contains(it.details.roomId)) + } + .toPersistentList() + } + + return InviteListState( + inviteList = inviteList, + declineConfirmationDialog = decliningInvite.value?.let { + InviteDeclineConfirmationDialog.Visible( + isDirect = it.isDirect, + name = it.roomName, + ) + } ?: InviteDeclineConfirmationDialog.Hidden, + acceptedAction = acceptedAction.value, + declinedAction = declinedAction.value, + eventSink = ::handleEvent + ) + } + + private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<Async<RoomId>>) = launch { + suspend { + client.getRoom(roomId)?.use { + it.join().getOrThrow() + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) + analyticsService.capture(it.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) + } + roomId + }.runCatchingUpdatingState(acceptedAction) + } + + private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch { + suspend { + client.getRoom(roomId)?.use { + it.leave().getOrThrow() + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) + } + Unit + }.runCatchingUpdatingState(declinedAction) + } + + private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run { + val i = inviter + val avatarData = if (isDirect && i != null) + AvatarData( + id = i.userId.value, + name = i.displayName, + url = i.avatarUrl, + size = AvatarSize.RoomInviteItem, + ) + else + AvatarData( + id = roomId.value, + name = name, + url = avatarURLString, + size = AvatarSize.RoomInviteItem, + ) + + val alias = if (isDirect) + inviter?.userId?.value + else + canonicalAlias + + InviteListInviteSummary( + roomId = roomId, + roomName = name, + roomAlias = alias, + roomAvatarData = avatarData, + isDirect = isDirect, + isNew = !seen, + sender = if (isDirect) null else inviter?.run { + InviteSender( + userId = userId, + displayName = displayName ?: "", + avatarData = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = AvatarSize.InviteSender, + ), + ) + }, + ) + } +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt new file mode 100644 index 0000000000..5a7761ebc0 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.invitelist.impl.model.InviteListInviteSummary +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class InviteListState( + val inviteList: ImmutableList<InviteListInviteSummary>, + val declineConfirmationDialog: InviteDeclineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, + val acceptedAction: Async<RoomId> = Async.Uninitialized, + val declinedAction: Async<Unit> = Async.Uninitialized, + val eventSink: (InviteListEvents) -> Unit = {} +) + +sealed interface InviteDeclineConfirmationDialog { + object Hidden : InviteDeclineConfirmationDialog + data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt new file mode 100644 index 0000000000..d4d1f5c166 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListStateProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.invitelist.impl.model.InviteListInviteSummary +import io.element.android.features.invitelist.impl.model.InviteSender +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class InviteListStateProvider : PreviewParameterProvider<InviteListState> { + override val values: Sequence<InviteListState> + get() = sequenceOf( + aInviteListState(), + aInviteListState().copy(inviteList = persistentListOf()), + aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(true, "Alice")), + aInviteListState().copy(declineConfirmationDialog = InviteDeclineConfirmationDialog.Visible(false, "Some Room")), + aInviteListState().copy(acceptedAction = Async.Failure(Throwable("Whoops"))), + aInviteListState().copy(declinedAction = Async.Failure(Throwable("Whoops"))), + ) +} + +internal fun aInviteListState() = InviteListState( + inviteList = aInviteListInviteSummaryList(), +) + +internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> { + return persistentListOf( + InviteListInviteSummary( + roomId = RoomId("!id1:example.com"), + roomName = "Room 1", + roomAlias = "#room:example.org", + sender = InviteSender( + userId = UserId("@alice:example.org"), + displayName = "Alice" + ), + ), + InviteListInviteSummary( + roomId = RoomId("!id2:example.com"), + roomName = "Room 2", + sender = InviteSender( + userId = UserId("@bob:example.org"), + displayName = "Bob" + ), + ), + InviteListInviteSummary( + roomId = RoomId("!id3:example.com"), + roomName = "Alice", + roomAlias = "@alice:example.com" + ), + ) +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt new file mode 100644 index 0000000000..e2e5927a66 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.invitelist.impl.components.InviteSummaryRow +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun InviteListView( + state: InviteListState, + modifier: Modifier = Modifier, + onBackClicked: () -> Unit = {}, + onInviteAccepted: (RoomId) -> Unit = {}, +) { + if (state.acceptedAction is Async.Success) { + LaunchedEffect(state.acceptedAction) { + onInviteAccepted(state.acceptedAction.data) + } + } + + InviteListContent( + state = state, + modifier = modifier, + onBackClicked = onBackClicked, + ) + + if (state.declineConfirmationDialog is InviteDeclineConfirmationDialog.Visible) { + val contentResource = if (state.declineConfirmationDialog.isDirect) + R.string.screen_invites_decline_direct_chat_message + else + R.string.screen_invites_decline_chat_message + + val titleResource = if (state.declineConfirmationDialog.isDirect) + R.string.screen_invites_decline_direct_chat_title + else + R.string.screen_invites_decline_chat_title + + ConfirmationDialog( + content = stringResource(contentResource, state.declineConfirmationDialog.name), + title = stringResource(titleResource), + submitText = stringResource(CommonStrings.action_decline), + cancelText = stringResource(CommonStrings.action_cancel), + emphasizeSubmitButton = true, + onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, + onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } + ) + } + + if (state.acceptedAction is Async.Failure) { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + title = stringResource(CommonStrings.common_error), + submitText = stringResource(CommonStrings.action_ok), + onDismiss = { state.eventSink(InviteListEvents.DismissAcceptError) } + ) + } + + if (state.declinedAction is Async.Failure) { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + title = stringResource(CommonStrings.common_error), + submitText = stringResource(CommonStrings.action_ok), + onDismiss = { state.eventSink(InviteListEvents.DismissDeclineError) } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun InviteListContent( + state: InviteListState, + modifier: Modifier = Modifier, + onBackClicked: () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClicked) + }, + title = { + Text( + text = stringResource(CommonStrings.action_invites_list), + style = ElementTheme.typography.aliasScreenTitle, + ) + } + ) + }, + content = { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + if (state.inviteList.isEmpty()) { + Spacer(Modifier.size(80.dp)) + + Text( + text = stringResource(R.string.screen_invites_empty_list), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + itemsIndexed( + items = state.inviteList, + ) { index, invite -> + InviteSummaryRow( + invite = invite, + onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) }, + onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) }, + ) + + if (index != state.inviteList.lastIndex) { + Divider() + } + } + } + } + } + } + ) +} + +@Preview +@Composable +internal fun InviteListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun InviteListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: InviteListState) { + InviteListView(state) +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt new file mode 100644 index 0000000000..c2bbd5b023 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.invitelist.impl.R +import io.element.android.features.invitelist.impl.model.InviteListInviteSummary +import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider +import io.element.android.features.invitelist.impl.model.InviteSender +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +private val minHeight = 72.dp + +@Composable +internal fun InviteSummaryRow( + invite: InviteListInviteSummary, + modifier: Modifier = Modifier, + onAcceptClicked: () -> Unit = {}, + onDeclineClicked: () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxWidth() + .heightIn(min = minHeight) + ) { + DefaultInviteSummaryRow( + invite = invite, + onAcceptClicked = onAcceptClicked, + onDeclineClicked = onDeclineClicked, + ) + } +} + +@Composable +internal fun DefaultInviteSummaryRow( + invite: InviteListInviteSummary, + onAcceptClicked: () -> Unit = {}, + onDeclineClicked: () -> Unit = {}, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.Top + ) { + Avatar( + invite.roomAvatarData, + ) + + Column( + modifier = Modifier + .padding(start = 16.dp, end = 4.dp) + .alignByBaseline() + .weight(1f) + ) { + val bonusPadding = if (invite.isNew) 12.dp else 0.dp + + // Name + Text( + text = invite.roomName, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgMedium, + modifier = Modifier.padding(end = bonusPadding), + ) + + // ID or Alias + invite.roomAlias?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = bonusPadding), + ) + } + + // Sender + invite.sender?.let { sender -> + SenderRow(sender = sender) + } + + // CTAs + Row(Modifier.padding(top = 12.dp)) { + OutlinedButton( + content = { Text(stringResource(CommonStrings.action_decline), style = ElementTheme.typography.aliasButtonText) }, + onClick = onDeclineClicked, + modifier = Modifier + .weight(1f) + .heightIn(max = 36.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Button( + content = { Text(stringResource(CommonStrings.action_accept), style = ElementTheme.typography.aliasButtonText) }, + onClick = onAcceptClicked, + modifier = Modifier + .weight(1f) + .heightIn(max = 36.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + ) + } + } + + UnreadIndicatorAtom(isVisible = invite.isNew) + } +} + +@Composable +private fun SenderRow(sender: InviteSender) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + Avatar( + avatarData = sender.avatarData, + ) + Text( + text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text -> + val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s") + AnnotatedString( + text = text, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle( + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ), + start = senderNameStart, + end = senderNameStart + sender.displayName.length + ) + ) + ) + }, + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.secondary, + ) + } +} + +@Preview +@Composable +internal fun InviteSummaryRowLightPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = + ElementPreviewLight { ContentToPreview(data) } + +@Preview +@Composable +internal fun InviteSummaryRowDarkPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = + ElementPreviewDark { ContentToPreview(data) } + +@Composable +private fun ContentToPreview(data: InviteListInviteSummary) { + InviteSummaryRow(data) +} diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt new file mode 100644 index 0000000000..cb695d4eda --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +data class InviteListInviteSummary( + val roomId: RoomId, + val roomName: String = "", + val roomAlias: String? = null, + val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem), + val sender: InviteSender? = null, + val isDirect: Boolean = false, + val isNew: Boolean = false, +) + +data class InviteSender constructor( + val userId: UserId, + val displayName: String, + val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender), +) diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt new file mode 100644 index 0000000000..c872d05817 --- /dev/null +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl.model + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteListInviteSummary> { + override val values: Sequence<InviteListInviteSummary> + get() = sequenceOf( + aInviteListInviteSummary(), + aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"), + aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true), + aInviteListInviteSummary().copy(roomName = "Alice", sender = null), + aInviteListInviteSummary().copy(isNew = true) + ) +} + +fun aInviteListInviteSummary() = InviteListInviteSummary( + roomId = RoomId("!room1:example.com"), + roomName = "Some room with a long name that will truncate", + sender = InviteSender( + userId = UserId("@alice-with-a-long-mxid:example.org"), + displayName = "Alice with a long name" + ), +) diff --git a/features/invitelist/impl/src/main/res/values-cs/translations.xml b/features/invitelist/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..d4c60464b3 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string> + <string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string> + <string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"</string> + <string name="screen_invites_decline_direct_chat_title">"Odmítnout chat"</string> + <string name="screen_invites_empty_list">"Žádné pozvánky"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..1e2fcc2e86 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Möchten Sie den Beitritt zu %1$s wirklich ablehnen?"</string> + <string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string> + <string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string> + <string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string> + <string name="screen_invites_empty_list">"Keine Einladungen"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values-fr/translations.xml b/features/invitelist/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..677fadd539 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Voulez-vous vraiment refuser l‘invitation à rejoindre %1$s ?"</string> + <string name="screen_invites_decline_chat_title">"Refuser l\'invitation"</string> + <string name="screen_invites_decline_direct_chat_message">"Voulez-vous vraiment refuser ce chat privé avec %1$s ?"</string> + <string name="screen_invites_decline_direct_chat_title">"Refuser le chat"</string> + <string name="screen_invites_empty_list">"Aucune invitation"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité"</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values-it/translations.xml b/features/invitelist/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..5f31ef01ba --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values-ro/translations.xml b/features/invitelist/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..3f00d32337 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Sigur doriți să refuzați alăturarea la %1$s?"</string> + <string name="screen_invites_decline_chat_title">"Refuzați invitația"</string> + <string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string> + <string name="screen_invites_decline_direct_chat_title">"Refuzați conversația"</string> + <string name="screen_invites_empty_list">"Nicio invitație"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) v-a invitat."</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values-sk/translations.xml b/features/invitelist/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..2875466fc7 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"</string> + <string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string> + <string name="screen_invites_decline_direct_chat_message">"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"</string> + <string name="screen_invites_decline_direct_chat_title">"Odmietnuť konverzáciu"</string> + <string name="screen_invites_empty_list">"Žiadne pozvánky"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval/a"</string> +</resources> diff --git a/features/invitelist/impl/src/main/res/values/localazy.xml b/features/invitelist/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..7c2c019466 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string> + <string name="screen_invites_decline_chat_title">"Decline invite"</string> + <string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string> + <string name="screen_invites_decline_direct_chat_title">"Decline chat"</string> + <string name="screen_invites_empty_list">"No Invites"</string> + <string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string> +</resources> diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt new file mode 100644 index 0000000000..1dd9068a1f --- /dev/null +++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt @@ -0,0 +1,501 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.invitelist.test.FakeSeenInvitesStore +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class InviteListPresenterTests { + + @Test + fun `present - starts empty, adds invites when received`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = createPresenter( + FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.inviteList).isEmpty() + + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary())) + + val withInviteState = awaitItem() + Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) + Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) + } + } + + @Test + fun `present - uses user ID and avatar for direct invites`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() + val presenter = createPresenter( + FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val withInviteState = awaitItem() + Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) + Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + Truth.assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value) + Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) + Truth.assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo( + AvatarData( + id = A_USER_ID.value, + name = A_USER_NAME, + url = AN_AVATAR_URL, + size = AvatarSize.RoomInviteItem, + ) + ) + Truth.assertThat(withInviteState.inviteList[0].sender).isNull() + } + } + + @Test + fun `present - includes sender details for room invites`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val presenter = createPresenter( + FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val withInviteState = awaitItem() + Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) + Truth.assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME) + Truth.assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID) + Truth.assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo( + AvatarData( + id = A_USER_ID.value, + name = A_USER_NAME, + url = AN_AVATAR_URL, + size = AvatarSize.InviteSender, + ) + ) + } + } + + @Test + fun `present - shows confirm dialog for declining direct chat invites`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() + val presenter = InviteListPresenter( + FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ), + FakeSeenInvitesStore(), + FakeAnalyticsService(), + FakeNotificationDrawerManager() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + val newState = awaitItem() + Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java) + + val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible + Truth.assertThat(confirmDialog.isDirect).isTrue() + Truth.assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME) + } + } + + @Test + fun `present - shows confirm dialog for declining room invites`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val presenter = createPresenter( + FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + val newState = awaitItem() + Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Visible::class.java) + + val confirmDialog = newState.declineConfirmationDialog as InviteDeclineConfirmationDialog.Visible + Truth.assertThat(confirmDialog.isDirect).isFalse() + Truth.assertThat(confirmDialog.name).isEqualTo(A_ROOM_NAME) + } + } + + @Test + fun `present - hides confirm dialog when cancelling`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val presenter = createPresenter( + FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + skipItems(1) + + originalState.eventSink(InviteListEvents.CancelDeclineInvite) + + val newState = awaitItem() + Truth.assertThat(newState.declineConfirmationDialog).isInstanceOf(InviteDeclineConfirmationDialog.Hidden::class.java) + } + } + + @Test + fun `present - declines invite after confirming`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val fakeNotificationDrawerManager = FakeNotificationDrawerManager() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + skipItems(1) + + originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) + + skipItems(2) + + Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1) + } + } + + @Test + fun `present - declines invite after confirming and sets state on error`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client) + val ex = Throwable("Ruh roh!") + room.givenLeaveRoomError(ex) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + skipItems(1) + + originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) + + skipItems(1) + + val newState = awaitItem() + + Truth.assertThat(newState.declinedAction).isEqualTo(Async.Failure<Unit>(ex)) + } + } + + @Test + fun `present - dismisses declining error state`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client) + val ex = Throwable("Ruh roh!") + room.givenLeaveRoomError(ex) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.DeclineInvite(originalState.inviteList[0])) + + skipItems(1) + + originalState.eventSink(InviteListEvents.ConfirmDeclineInvite) + + skipItems(2) + + originalState.eventSink(InviteListEvents.DismissDeclineError) + + val newState = awaitItem() + + Truth.assertThat(newState.declinedAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - accepts invites and sets state on success`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val fakeNotificationDrawerManager = FakeNotificationDrawerManager() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) + + val newState = awaitItem() + + Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Success(A_ROOM_ID)) + Truth.assertThat(fakeNotificationDrawerManager.getClearMembershipNotificationForRoomCount(client.sessionId, A_ROOM_ID)).isEqualTo(1) + } + } + + @Test + fun `present - accepts invites and sets state on error`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client) + val ex = Throwable("Ruh roh!") + room.givenJoinRoomResult(Result.failure(ex)) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) + + Truth.assertThat(awaitItem().acceptedAction).isEqualTo(Async.Failure<RoomId>(ex)) + } + } + + @Test + fun `present - dismisses accepting error state`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val client = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ) + val room = FakeMatrixRoom() + val presenter = createPresenter(client) + val ex = Throwable("Ruh roh!") + room.givenJoinRoomResult(Result.failure(ex)) + client.givenGetRoomResult(A_ROOM_ID, room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val originalState = awaitItem() + originalState.eventSink(InviteListEvents.AcceptInvite(originalState.inviteList[0])) + + skipItems(1) + + originalState.eventSink(InviteListEvents.DismissAcceptError) + + val newState = awaitItem() + Truth.assertThat(newState.acceptedAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - stores seen invites when received`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val store = FakeSeenInvitesStore() + val presenter = InviteListPresenter( + FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ), + store, + FakeAnalyticsService(), + FakeNotificationDrawerManager() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + awaitItem() + + // When one invite is received, that ID is saved + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary())) + + awaitItem() + Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID)) + + // When a second is added, both are saved + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + + awaitItem() + Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2)) + + // When they're both dismissed, an empty set is saved + roomSummaryDataSource.postInviteRooms(listOf()) + + awaitItem() + Truth.assertThat(store.getProvidedRoomIds()).isEmpty() + } + } + + @Test + fun `present - marks invite as new if they're unseen`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val store = FakeSeenInvitesStore() + store.publishRoomIds(setOf(A_ROOM_ID)) + val presenter = InviteListPresenter( + FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource, + ), + store, + FakeAnalyticsService(), + FakeNotificationDrawerManager() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + awaitItem() + + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + skipItems(1) + + val withInviteState = awaitItem() + Truth.assertThat(withInviteState.inviteList.size).isEqualTo(2) + Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) + Truth.assertThat(withInviteState.inviteList[0].isNew).isFalse() + Truth.assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2) + Truth.assertThat(withInviteState.inviteList[1].isNew).isTrue() + } + } + + private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource { + postInviteRooms( + listOf( + RoomSummary.Filled( + RoomSummaryDetails( + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + avatarURLString = null, + isDirect = false, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + inviter = RoomMember( + userId = A_USER_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false, + ) + ) + ) + ) + ) + return this + } + + private suspend fun FakeRoomSummaryDataSource.withDirectChatInvitation(): FakeRoomSummaryDataSource { + postInviteRooms( + listOf( + RoomSummary.Filled( + RoomSummaryDetails( + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + avatarURLString = null, + isDirect = true, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + inviter = RoomMember( + userId = A_USER_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0, + normalizedPowerLevel = 0, + isIgnored = false, + ) + ) + ) + ) + ) + return this + } + + private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled( + RoomSummaryDetails( + roomId = id, + name = A_ROOM_NAME, + avatarURLString = null, + isDirect = false, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + ) + ) + + private fun createPresenter( + client: MatrixClient, + seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(), + fakeAnalyticsService: AnalyticsService = FakeAnalyticsService(), + notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager() + ) = InviteListPresenter( + client, + seenInvitesStore, + fakeAnalyticsService, + notificationDrawerManager + ) +} diff --git a/features/invitelist/test/build.gradle.kts b/features/invitelist/test/build.gradle.kts new file mode 100644 index 0000000000..ce9b0dabe4 --- /dev/null +++ b/features/invitelist/test/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.invitelist.test" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + api(projects.features.invitelist.api) +} diff --git a/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt new file mode 100644 index 0000000000..486d3fb4a8 --- /dev/null +++ b/features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.invitelist.test + +import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeSeenInvitesStore : SeenInvitesStore { + + private val existing = MutableStateFlow(emptySet<RoomId>()) + private var provided: Set<RoomId>? = null + + fun publishRoomIds(invites: Set<RoomId>) { + existing.value = invites + } + + fun getProvidedRoomIds() = provided + + override fun seenRoomIds(): Flow<Set<RoomId>> = existing + + override suspend fun markAsSeen(roomIds: Set<RoomId>) { + provided = roomIds.toSet() + } +} diff --git a/features/leaveroom/api/build.gradle.kts b/features/leaveroom/api/build.gradle.kts new file mode 100644 index 0000000000..83ca28b39a --- /dev/null +++ b/features/leaveroom/api/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.leaveroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrix.api) + ksp(libs.showkase.processor) +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt new file mode 100644 index 0000000000..d1a3369ac6 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.api + +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface LeaveRoomEvent { + data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent + object HideConfirmation : LeaveRoomEvent + data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent + object HideError : LeaveRoomEvent +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt new file mode 100644 index 0000000000..dd1f83691e --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomPresenter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.api + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter + +interface LeaveRoomPresenter : Presenter<LeaveRoomState> { + @Composable + override fun present(): LeaveRoomState +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt new file mode 100644 index 0000000000..7cb9926677 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.api + +import io.element.android.libraries.matrix.api.core.RoomId + +data class LeaveRoomState( + val confirmation: Confirmation = Confirmation.Hidden, + val progress: Progress = Progress.Hidden, + val error: Error = Error.Hidden, + val eventSink: (LeaveRoomEvent) -> Unit = {}, +) { + sealed interface Confirmation { + object Hidden : Confirmation + data class Generic(val roomId: RoomId) : Confirmation + data class PrivateRoom(val roomId: RoomId) : Confirmation + data class LastUserInRoom(val roomId: RoomId) : Confirmation + } + + sealed interface Progress { + object Hidden : Progress + object Shown : Progress + } + + sealed interface Error { + object Hidden : Error + object Shown : Error + } +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt new file mode 100644 index 0000000000..e9b08bcd18 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomStateProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.api + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId + +class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> { + override val values: Sequence<LeaveRoomState> + get() = sequenceOf( + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID), + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Shown, + error = LeaveRoomState.Error.Hidden, + ), + LeaveRoomState( + confirmation = LeaveRoomState.Confirmation.Hidden, + progress = LeaveRoomState.Progress.Hidden, + error = LeaveRoomState.Error.Shown, + ), + ) +} + +private val A_ROOM_ID = RoomId("!aRoomId:aDomain") diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt new file mode 100644 index 0000000000..92cacd7fcb --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.api + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LeaveRoomView( + state: LeaveRoomState +) { + LeaveRoomConfirmationDialog(state) + LeaveRoomProgressDialog(state) + LeaveRoomErrorDialog(state) +} + +@Composable +private fun LeaveRoomConfirmationDialog( + state: LeaveRoomState, +) { + when (state.confirmation) { + is LeaveRoomState.Confirmation.Hidden -> {} + is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog( + text = CommonStrings.leave_room_alert_private_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + + is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog( + text = CommonStrings.leave_room_alert_empty_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + + is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog( + text = CommonStrings.leave_room_alert_subtitle, + roomId = state.confirmation.roomId, + eventSink = state.eventSink, + ) + } +} + +@Composable +private fun LeaveRoomConfirmationDialog( + @StringRes text: Int, + roomId: RoomId, + eventSink: (LeaveRoomEvent) -> Unit, +) { + ConfirmationDialog( + title = stringResource(CommonStrings.action_leave_room), + content = stringResource(text), + submitText = stringResource(CommonStrings.action_leave), + onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, + onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) }, + ) +} + +@Composable +private fun LeaveRoomProgressDialog( + state: LeaveRoomState, +) { + when (state.progress) { + is LeaveRoomState.Progress.Hidden -> {} + is LeaveRoomState.Progress.Shown -> ProgressDialog( + text = stringResource(CommonStrings.common_leaving_room), + ) + } +} + +@Composable +private fun LeaveRoomErrorDialog( + state: LeaveRoomState, +) { + when (state.error) { + is LeaveRoomState.Error.Hidden -> {} + is LeaveRoomState.Error.Shown -> ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + onDismiss = { state.eventSink(LeaveRoomEvent.HideError) } + ) + } +} + +@Preview +@Composable +internal fun LeaveRoomViewLightPreview( + @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState +) = ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun LeaveRoomViewDarkPreview( + @PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState +) = ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LeaveRoomState) { + Box( + modifier = Modifier.size(300.dp, 300.dp), + propagateMinConstraints = true, + ) { + LeaveRoomView(state = state) + } +} diff --git a/features/leaveroom/fake/build.gradle.kts b/features/leaveroom/fake/build.gradle.kts new file mode 100644 index 0000000000..19a057d5ba --- /dev/null +++ b/features/leaveroom/fake/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.leaveroom.fake" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + api(projects.features.leaveroom.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.coroutines.core) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) +} diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt new file mode 100644 index 0000000000..28c12b54ba --- /dev/null +++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFake.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.fake + +import androidx.compose.runtime.Composable +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +import javax.inject.Inject + +class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter { + + val events = mutableListOf<LeaveRoomEvent>() + + private fun handleEvent(event: LeaveRoomEvent) { + events += event + } + + private var state = LeaveRoomState(eventSink = ::handleEvent) + set(value) { + field = value.copy(eventSink = ::handleEvent) + } + + fun givenState(state: LeaveRoomState) { + this.state = state + } + + @Composable + override fun present(): LeaveRoomState = state +} diff --git a/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt new file mode 100644 index 0000000000..b20b88db1c --- /dev/null +++ b/features/leaveroom/fake/src/main/kotlin/io/element/android/features/leaveroom/fake/LeaveRoomPresenterFakeModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.fake + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.libraries.di.SessionScope + +@Module +@ContributesTo(SessionScope::class) +interface LeaveRoomPresenterFakeModule { + @Binds + fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterFake): LeaveRoomPresenter +} diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts new file mode 100644 index 0000000000..8d26ea9271 --- /dev/null +++ b/features/leaveroom/impl/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.leaveroom.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + api(projects.features.leaveroom.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.coroutines.core) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt new file mode 100644 index 0000000000..2ad35b38c9 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom +import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class LeaveRoomPresenterImpl @Inject constructor( + private val client: MatrixClient, + private val roomMembershipObserver: RoomMembershipObserver, + private val dispatchers: CoroutineDispatchers, +) : LeaveRoomPresenter { + @Composable + override fun present(): LeaveRoomState { + val scope = rememberCoroutineScope() + val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) } + val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) } + val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) } + + return LeaveRoomState( + confirmation = confirmation.value, + progress = progress.value, + error = error.value, + ) { event -> + when (event) { + is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) { + showLeaveRoomAlert( + matrixClient = client, + roomId = event.roomId, + confirmation = confirmation, + ) + } + + is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden + is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) { + client.leaveRoom( + roomId = event.roomId, + roomMembershipObserver = roomMembershipObserver, + confirmation = confirmation, + progress = progress, + error = error, + ) + } + + is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden + } + } + } +} + +private suspend fun showLeaveRoomAlert( + matrixClient: MatrixClient, + roomId: RoomId, + confirmation: MutableState<LeaveRoomState.Confirmation>, +) { + matrixClient.getRoom(roomId)?.use { room -> + confirmation.value = when { + !room.isPublic -> PrivateRoom(roomId) + room.joinedMemberCount == 1L -> LastUserInRoom(roomId) + else -> Generic(roomId) + } + } +} + +private suspend fun MatrixClient.leaveRoom( + roomId: RoomId, + roomMembershipObserver: RoomMembershipObserver, + confirmation: MutableState<LeaveRoomState.Confirmation>, + progress: MutableState<LeaveRoomState.Progress>, + error: MutableState<LeaveRoomState.Error>, +) { + confirmation.value = LeaveRoomState.Confirmation.Hidden + progress.value = LeaveRoomState.Progress.Shown + getRoom(roomId)?.use { room -> + room.leave().onSuccess { + roomMembershipObserver.notifyUserLeftRoom(room.roomId) + }.onFailure { + Timber.e(it, "Error while leaving room ${room.displayName} - ${room.roomId}") + error.value = LeaveRoomState.Error.Shown + } + } + progress.value = LeaveRoomState.Progress.Hidden +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt new file mode 100644 index 0000000000..65403adb60 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.impl + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.libraries.di.SessionScope + +@Module +@ContributesTo(SessionScope::class) +interface LeaveRoomPresenterImplModule { + @Binds + fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter +} diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt new file mode 100644 index 0000000000..03eeb3adc6 --- /dev/null +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.leaveroom.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LeaveRoomPresenterImplTest { + + @Test + fun `present - initial state hides all dialogs`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden) + assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden) + assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden) + } + } + + @Test + fun `present - show generic confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom() + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID)) + } + } + + @Test + fun `present - show private room confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(isPublic = false), + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - show last user in room confirmation`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(joinedMemberCount = 1), + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + val confirmationState = awaitItem() + assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - leaving a room leaves the room`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(), + ) + }, + roomMembershipObserver = roomMembershipObserver + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + // Membership observer should receive a 'left room' change + assertThat(roomMembershipObserver.updates.first().change).isEqualTo(MembershipChange.LEFT) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show error if leave room fails`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + givenLeaveRoomError(RuntimeException("Blimey!")) + }, + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + skipItems(1) // Skip show progress state + val errorState = awaitItem() + assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show progress indicator while leaving a room`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom(), + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + val progressState = awaitItem() + assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown) + val finalState = awaitItem() + assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden) + } + } + + @Test + fun `present - hide error hides the error`() = runTest { + val presenter = createPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeMatrixRoom().apply { + givenLeaveRoomError(RuntimeException("Blimey!")) + }, + ) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID)) + skipItems(1) // Skip show progress state + val errorState = awaitItem() + assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown) + skipItems(1) // Skip hide progress state + errorState.eventSink(LeaveRoomEvent.HideError) + val hiddenErrorState = awaitItem() + assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden) + } + } +} + +private fun TestScope.createPresenter( + client: MatrixClient = FakeMatrixClient(), + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), +): LeaveRoomPresenter = LeaveRoomPresenterImpl( + client = client, + roomMembershipObserver = roomMembershipObserver, + dispatchers = testCoroutineDispatchers(false), +) diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts new file mode 100644 index 0000000000..6de297fe77 --- /dev/null +++ b/features/location/api/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +import java.util.Properties + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +fun readLocalProperty(name: String) = Properties().apply { + try { + load(rootProject.file("local.properties").reader()) + } catch (ignored: java.io.IOException) { + } +}[name] + +android { + namespace = "io.element.android.features.location.api" + + defaultConfig { + resValue( + type = "string", + name = "maptiler_api_key", + value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") + ?: readLocalProperty("services.maptiler.apikey") as? String + ?: "" + ) + } +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.core) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(libs.coil.compose) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt new file mode 100644 index 0000000000..8d801b37a8 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?""" + +@Parcelize +data class Location( + val lat: Double, + val lon: Double, + val accuracy: Float, +) : Parcelable { + companion object { + fun fromGeoUri(geoUri: String): Location? { + val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null + return Location( + lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null, + lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null, + accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f, + ) + } + } + + fun toGeoUri(): String { + return "geo:$lat,$lon;u=$accuracy" + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt new file mode 100644 index 0000000000..a1b43d6a5c --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +/** + * The "Send location" screen. + * + * Allows a user to share a location message within a room. + */ +interface SendLocationEntryPoint : SimpleFeatureEntryPoint diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt new file mode 100644 index 0000000000..3c429dfa63 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs + +interface ShowLocationEntryPoint : FeatureEntryPoint { + + data class Inputs(val location: Location, val description: String?) : NodeInputs + + fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt new file mode 100644 index 0000000000..14390d0f4f --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import io.element.android.features.location.api.internal.StaticMapPlaceholder +import io.element.android.features.location.api.internal.staticMapUrl +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.theme.ElementTheme +import timber.log.Timber +import io.element.android.libraries.designsystem.R as DesignSystemR + +/** + * Shows a static map image downloaded via a third party service's static maps API. + */ +@Composable +fun StaticMapView( + lat: Double, + lon: Double, + zoom: Double, + contentDescription: String?, + modifier: Modifier = Modifier, + darkMode: Boolean = !ElementTheme.isLightTheme, +) { + // Using BoxWithConstraints to: + // 1) Size the inner Image to the same Dp size of the outer BoxWithConstraints. + // 2) Request the static map image of the exact required size in Px to fill the AsyncImage. + BoxWithConstraints( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + val context = LocalContext.current + var retryHash by remember { mutableStateOf(0) } + val painter = rememberAsyncImagePainter( + model = if (constraints.isZero) { + // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception). + null + } else { + ImageRequest.Builder(LocalContext.current) + .data( + staticMapUrl( + context = context, + lat = lat, + lon = lon, + zoom = zoom, + darkMode = darkMode, + // Size the map based on DP rather than pixels, as otherwise the features and attribution + // end up being illegibly tiny on high density displays. + width = constraints.maxWidth.toDp().value.toInt(), + height = constraints.maxHeight.toDp().value.toInt(), + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .setParameter("retry_hash", retryHash, memoryCacheKey = null) + .build() + }.apply { + Timber.d("Static map image request: ${this?.data}") + } + ) + + if (painter.state is AsyncImagePainter.State.Success) { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + // The returned image can be smaller than the requested size due to the static maps API having + // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details. + // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. + contentScale = ContentScale.Fit, + ) + Icon( + resourceId = DesignSystemR.drawable.pin, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.align { size, space, _ -> + // Center bottom edge of pin (i.e. its arrow) to center of screen + IntOffset( + x = (space.width - size.width) / 2, + y = (space.height / 2) - size.height, + ) + } + ) + } else { + StaticMapPlaceholder( + showProgress = painter.state is AsyncImagePainter.State.Loading, + contentDescription = contentDescription, + modifier = Modifier.size(width = maxWidth, height = maxHeight), + onLoadMapClick = { retryHash++ } + ) + } + } +} + +@DayNightPreviews +@Composable +fun StaticMapViewPreview() = ElementPreview { + StaticMapView( + lat = 0.0, + lon = 0.0, + zoom = 0.0, + contentDescription = null, + modifier = Modifier.size(400.dp), + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt new file mode 100644 index 0000000000..b6f21a4512 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api.internal + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import io.element.android.features.location.api.R +import io.element.android.libraries.theme.ElementTheme + +/** + * Provides the URL to an image that contains a statically-generated map of the given location. + */ +fun staticMapUrl( + context: Context, + lat: Double, + lon: Double, + zoom: Double, + width: Int, + height: Int, + darkMode: Boolean, +): String { + return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft" +} + +/** + * Utility function to remember the tile server URL based on the current theme. + */ +@Composable +fun rememberTileStyleUrl(): String { + val context = LocalContext.current + val darkMode = !ElementTheme.isLightTheme + return remember(darkMode) { + tileStyleUrl( + context = context, + darkMode = darkMode + ) + } +} + +/** + * Provides the URL to a MapLibre style document, used for rendering dynamic maps. + */ +private fun tileStyleUrl( + context: Context, + darkMode: Boolean, +): String { + return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}" +} + +private fun baseUrl(darkMode: Boolean) = + "https://api.maptiler.com/maps/" + + if (darkMode) + "dea61faf-292b-4774-9660-58fcef89a7f3" + else + "9bc819c8-e627-474a-a348-ec144fe3d810" + +private val Context.apiKey: String + get() = getString(R.string.maptiler_api_key) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt new file mode 100644 index 0000000000..d36ead5b28 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api.internal + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.features.location.api.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun StaticMapPlaceholder( + showProgress: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + onLoadMapClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.blurred_map), + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.FillBounds, + ) + if (showProgress) { + CircularProgressIndicator() + } else { + Box( + modifier = modifier.clickable(onClick = onLoadMapClick), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null + ) + Text(text = stringResource(id = CommonStrings.action_static_map_load)) + } + } + } + } +} + +@DayNightPreviews +@Composable +fun StaticMapPlaceholderPreview( + @PreviewParameter(BooleanParameterProvider::class) values: Boolean +) = ElementPreview { + StaticMapPlaceholder( + showProgress = values, + contentDescription = null, + modifier = Modifier.size(400.dp), + onLoadMapClick = {}, + ) +} + +internal class BooleanParameterProvider : PreviewParameterProvider<Boolean> { + override val values: Sequence<Boolean> + get() = sequenceOf(true, false) +} diff --git a/features/location/api/src/main/res/drawable-night/blurred_map.png b/features/location/api/src/main/res/drawable-night/blurred_map.png new file mode 100644 index 0000000000..7e90d568f1 Binary files /dev/null and b/features/location/api/src/main/res/drawable-night/blurred_map.png differ diff --git a/features/location/api/src/main/res/drawable/blurred_map.png b/features/location/api/src/main/res/drawable/blurred_map.png new file mode 100644 index 0000000000..365cf96786 Binary files /dev/null and b/features/location/api/src/main/res/drawable/blurred_map.png differ diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt new file mode 100644 index 0000000000..f3d1f72f22 --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.api + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +internal class LocationKtTest { + + @Test + fun `parseGeoUri - returns null for invalid urls`() { + assertThat(Location.fromGeoUri("")).isNull() + assertThat(Location.fromGeoUri("http://example.com/")).isNull() + assertThat(Location.fromGeoUri("geo:")).isNull() + assertThat(Location.fromGeoUri("geo:1.234")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,")).isNull() + assertThat(Location.fromGeoUri("geo:,1.234")).isNull() + assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull() + assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull() + assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull() + assertThat(Location.fromGeoUri("geo:not,good")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull() + assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull() + } + + @Test + fun `parseGeoUri - returns location for valid urls`() { + assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location( + lat = 1.234, + lon = 5.678, + accuracy = 0f, + )) + + assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location( + lat = 1.0, + lon = 5.0, + accuracy = 0f, + )) + + assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location( + lat = 1.234, + lon = 5.678, + accuracy = 3000f, + )) + + assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location( + lat = 1.0, + lon = 5.0, + accuracy = 3000f, + )) + + assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location( + lat = -1.234, + lon = -5.678, + accuracy = 9.10f, + )) + + assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location( + lat = -1.0, + lon = -5.0, + accuracy = 9.10f, + )) + } + + @Test + fun `encode geoUri - returns geoUri from a Location`() { + assertThat(Location(1.0,2.0,3.0f).toGeoUri()) + .isEqualTo("geo:1.0,2.0;u=3.0") + } +} diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts new file mode 100644 index 0000000000..1158b5f152 --- /dev/null +++ b/features/location/impl/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.location.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + api(projects.features.location.api) + implementation(projects.features.messages.api) + implementation(projects.libraries.maplibreCompose) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.di) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.androidutils) + implementation(projects.services.analytics.api) + implementation(libs.accompanist.permission) + implementation(projects.libraries.uiStrings) + implementation(libs.dagger) + implementation(projects.anvilannotations) + implementation(projects.services.toolbox.api) + anvil(projects.anvilcodegen) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.analytics.test) + testImplementation(projects.features.messages.test) +} diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b4f5d8f271 --- /dev/null +++ b/features/location/impl/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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.ACCESS_COARSE_LOCATION" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> +</manifest> diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt new file mode 100644 index 0000000000..da88598251 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/AndroidLocationActions.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.VisibleForTesting +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.show.LocationActions +import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidLocationActions @Inject constructor( + @ApplicationContext private val context: Context +) : LocationActions { + override fun share(location: Location, label: String?) { + runCatching { + val uri = Uri.parse(buildUrl(location, label)) + val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri) + val chooserIntent = Intent.createChooser(showMapsIntent, null) + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooserIntent) + }.onSuccess { + Timber.v("Open location succeed") + }.onFailure { + Timber.e(it, "Open location failed") + } + } + + override fun openSettings() { + context.openAppSettingsPage() + } +} + +@VisibleForTesting +internal fun buildUrl( + location: Location, + label: String?, + urlEncoder: (String) -> String = Uri::encode +): String { + // Ref: https://developer.android.com/guide/components/intents-common#ViewMap + val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon) + return if (label == null) { + base + } else { + "%s (%s)".format(base, urlEncoder(label)) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt new file mode 100644 index 0000000000..ac99be1f59 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/MapDefaults.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl + +import android.Manifest +import android.view.Gravity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.geometry.LatLng +import io.element.android.libraries.maplibre.compose.MapLocationSettings +import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings +import io.element.android.libraries.maplibre.compose.MapUiSettings +import io.element.android.libraries.theme.ElementTheme + +/** + * Common configuration values for the map. + */ +object MapDefaults { + val uiSettings: MapUiSettings + @Composable + @ReadOnlyComposable + get() = MapUiSettings( + compassEnabled = false, + rotationGesturesEnabled = false, + scrollGesturesEnabled = true, + tiltGesturesEnabled = false, + zoomGesturesEnabled = true, + logoGravity = Gravity.TOP, + attributionGravity = Gravity.TOP, + attributionTintColor = ElementTheme.colors.iconPrimary + ) + + val symbolManagerSettings: MapSymbolManagerSettings + get() = MapSymbolManagerSettings( + iconAllowOverlap = true + ) + + val locationSettings: MapLocationSettings + get() = MapLocationSettings( + locationEnabled = false, + ) + + val centerCameraPosition = CameraPosition.Builder() + .target(LatLng(49.843, 9.902056)) + .zoom(2.7) + .build() + + const val DEFAULT_ZOOM = 15.0 + + val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt new file mode 100644 index 0000000000..194bf31df7 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.permissions + +sealed interface PermissionsEvents { + object RequestPermissions : PermissionsEvents +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt new file mode 100644 index 0000000000..ccff16159e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.permissions + +import io.element.android.libraries.architecture.Presenter + +interface PermissionsPresenter : Presenter<PermissionsState> { + interface Factory { + fun create(permissions: List<String>): PermissionsPresenter + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt new file mode 100644 index 0000000000..85941ab7d3 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterImpl.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.permissions + +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.di.AppScope + +class PermissionsPresenterImpl @AssistedInject constructor( + @Assisted private val permissions: List<String> +) : PermissionsPresenter { + + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : PermissionsPresenter.Factory { + override fun create(permissions: List<String>): PermissionsPresenterImpl + } + + @OptIn(ExperimentalPermissionsApi::class) + @Composable + override fun present(): PermissionsState { + val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions) + + fun handleEvents(event: PermissionsEvents) { + when (event) { + PermissionsEvents.RequestPermissions -> multiplePermissionsState.launchMultiplePermissionRequest() + } + } + + return PermissionsState( + permissions = when { + multiplePermissionsState.allPermissionsGranted -> PermissionsState.Permissions.AllGranted + multiplePermissionsState.permissions.any { it.status.isGranted } -> PermissionsState.Permissions.SomeGranted + else -> PermissionsState.Permissions.NoneGranted + }, + shouldShowRationale = multiplePermissionsState.shouldShowRationale, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt new file mode 100644 index 0000000000..626cf93c23 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/permissions/PermissionsState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.permissions + +data class PermissionsState( + val permissions: Permissions = Permissions.NoneGranted, + val shouldShowRationale: Boolean = false, + val eventSink: (PermissionsEvents) -> Unit = {}, +) { + sealed interface Permissions { + object AllGranted : Permissions + object SomeGranted : Permissions + object NoneGranted : Permissions + } + + val isAnyGranted: Boolean + get() = permissions is Permissions.SomeGranted || permissions is Permissions.AllGranted +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt new file mode 100644 index 0000000000..9edf195e28 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint { + override fun createNode( + parentNode: Node, buildContext: BuildContext + ): SendLocationNode = parentNode.createNode(buildContext) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt new file mode 100644 index 0000000000..2f0686da27 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import io.element.android.features.location.api.Location + +sealed interface SendLocationEvents { + data class SendLocation( + val cameraPosition: CameraPosition, + val location: Location?, + ) : SendLocationEvents { + data class CameraPosition( + val lat: Double, + val lon: Double, + val zoom: Double, + ) + } + + object SwitchToMyLocationMode : SendLocationEvents + + object SwitchToPinLocationMode : SendLocationEvents + + object DismissDialog : SendLocationEvents + + object RequestPermissions : SendLocationEvents + + object OpenAppSettings : SendLocationEvents +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt new file mode 100644 index 0000000000..be4d3f0764 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +class SendLocationNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: SendLocationPresenter, + analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationSend)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + SendLocationView( + state = presenter.present(), + modifier = modifier, + navigateUp = ::navigateUp, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt new file mode 100644 index 0000000000..d06124539c --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.impl.MapDefaults +import io.element.android.features.location.impl.permissions.PermissionsEvents +import io.element.android.features.location.impl.permissions.PermissionsPresenter +import io.element.android.features.location.impl.permissions.PermissionsState +import io.element.android.features.location.impl.show.LocationActions +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class SendLocationPresenter @Inject constructor( + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val room: MatrixRoom, + private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContext, + private val locationActions: LocationActions, + private val systemClock: SystemClock, + private val buildMeta: BuildMeta, +) : Presenter<SendLocationState> { + + private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) + + @Composable + override fun present(): SendLocationState { + val permissionsState: PermissionsState = permissionsPresenter.present() + var mode: SendLocationState.Mode by remember { + mutableStateOf( + if (permissionsState.isAnyGranted) SendLocationState.Mode.SenderLocation + else SendLocationState.Mode.PinLocation + ) + } + val appName by remember { derivedStateOf { buildMeta.applicationName } } + var permissionDialog: SendLocationState.Dialog by remember { + mutableStateOf(SendLocationState.Dialog.None) + } + val scope = rememberCoroutineScope() + + LaunchedEffect(permissionsState.permissions) { + if (permissionsState.isAnyGranted) { + mode = SendLocationState.Mode.SenderLocation + permissionDialog = SendLocationState.Dialog.None + } + } + + fun handleEvents(event: SendLocationEvents) { + when (event) { + is SendLocationEvents.SendLocation -> scope.launch { + sendLocation(event, mode) + } + SendLocationEvents.SwitchToMyLocationMode -> when { + permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation + permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale + else -> permissionDialog = SendLocationState.Dialog.PermissionDenied + } + SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation + SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None + SendLocationEvents.OpenAppSettings -> { + locationActions.openSettings() + permissionDialog = SendLocationState.Dialog.None + } + SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + } + } + + return SendLocationState( + permissionDialog = permissionDialog, + mode = mode, + hasLocationPermission = permissionsState.isAnyGranted, + appName = appName, + eventSink = ::handleEvents, + ) + } + + private suspend fun sendLocation( + event: SendLocationEvents.SendLocation, + mode: SendLocationState.Mode, + ) { + when (mode) { + SendLocationState.Mode.PinLocation -> { + val geoUri = event.cameraPosition.toGeoUri() + room.sendLocation( + body = generateBody(geoUri, systemClock.epochMillis()), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.PIN + ) + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isLocation = true, + isReply = messageComposerContext.composerMode.isReply, + locationType = Composer.LocationType.PinDrop, + ) + ) + } + SendLocationState.Mode.SenderLocation -> { + val geoUri = event.toGeoUri() + room.sendLocation( + body = generateBody(geoUri, systemClock.epochMillis()), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.SENDER + ) + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isLocation = true, + isReply = messageComposerContext.composerMode.isReply, + locationType = Composer.LocationType.MyLocation, + ) + ) + } + } + } +} + +private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() + +private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" + +private fun generateBody(uri: String, epochMillis: Long): String { + val timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT) + return "Location was shared at $uri as of $timestamp" +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt new file mode 100644 index 0000000000..3aeec5f046 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +data class SendLocationState( + val permissionDialog: Dialog = Dialog.None, + val mode: Mode = Mode.PinLocation, + val hasLocationPermission: Boolean = false, + val appName: String = "AppName", + val eventSink: (SendLocationEvents) -> Unit = {}, +) { + sealed interface Mode { + object SenderLocation : Mode + object PinLocation : Mode + } + + sealed interface Dialog { + object None : Dialog + object PermissionRationale : Dialog + object PermissionDenied : Dialog + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt new file mode 100644 index 0000000000..15f16f593a --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +private const val APP_NAME = "ApplicationName" + +class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> { + override val values: Sequence<SendLocationState> + get() = sequenceOf( + SendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + appName = APP_NAME, + ), + SendLocationState( + permissionDialog = SendLocationState.Dialog.PermissionDenied, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + appName = APP_NAME, + ), + SendLocationState( + permissionDialog = SendLocationState.Dialog.PermissionRationale, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + appName = APP_NAME, + ), + SendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = true, + appName = APP_NAME, + ), + SendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.SenderLocation, + hasLocationPermission = true, + appName = APP_NAME, + ), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt new file mode 100644 index 0000000000..1a30c996a8 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.mapbox.mapboxsdk.camera.CameraPosition +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.MapDefaults +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.maplibre.compose.CameraMode +import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason +import io.element.android.libraries.maplibre.compose.MapboxMap +import io.element.android.libraries.maplibre.compose.rememberCameraPositionState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.designsystem.R as DesignSystemR + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SendLocationView( + state: SendLocationState, + modifier: Modifier = Modifier, + navigateUp: () -> Unit = {}, +) { + LaunchedEffect(Unit) { + state.eventSink(SendLocationEvents.RequestPermissions) + } + + when (state.permissionDialog) { + SendLocationState.Dialog.None -> Unit + SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( + onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) }, + onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, + appName = state.appName, + ) + SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( + onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) }, + onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, + appName = state.appName, + ) + } + + val cameraPositionState = rememberCameraPositionState { + position = MapDefaults.centerCameraPosition + } + + LaunchedEffect(state.mode) { + when (state.mode) { + SendLocationState.Mode.PinLocation -> { + cameraPositionState.cameraMode = CameraMode.NONE + } + SendLocationState.Mode.SenderLocation -> { + cameraPositionState.position = CameraPosition.Builder() + .zoom(MapDefaults.DEFAULT_ZOOM) + .build() + cameraPositionState.cameraMode = CameraMode.TRACKING + } + } + } + + LaunchedEffect(cameraPositionState.isMoving) { + if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { + state.eventSink(SendLocationEvents.SwitchToPinLocationMode) + } + } + + // BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually. + val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + BottomSheetScaffold( + sheetContent = { + Spacer(modifier = Modifier.height(16.dp)) + ListItem( + headlineContent = { + Text( + stringResource( + when (state.mode) { + SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action + SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action + } + ) + ) + }, + modifier = Modifier.clickable( + // target is null when the map hasn't loaded (or api key is wrong) so we disable the button + enabled = cameraPositionState.position.target != null + ) { + state.eventSink( + SendLocationEvents.SendLocation( + cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + lat = cameraPositionState.position.target!!.latitude, + lon = cameraPositionState.position.target!!.longitude, + zoom = cameraPositionState.position.zoom, + ), + cameraPositionState.location?.let { + Location( + lat = it.latitude, + lon = it.longitude, + accuracy = it.accuracy, + ) + } + ) + ) + navigateUp() + }, + leadingContent = { + Icon(Icons.Default.LocationOn, null) + }, + ) + Spacer(modifier = Modifier.height(16.dp + navBarPadding)) + }, + modifier = modifier, + scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), + ), + sheetDragHandle = {}, + sheetSwipeEnabled = false, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(CommonStrings.screen_share_location_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { + BackButton(onClick = navigateUp) + }, + ) + }, + ) { + Box( + modifier = Modifier + .padding(it) + .consumeWindowInsets(it), + contentAlignment = Alignment.Center + ) { + MapboxMap( + styleUri = rememberTileStyleUrl(), + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + uiSettings = MapDefaults.uiSettings, + symbolManagerSettings = MapDefaults.symbolManagerSettings, + locationSettings = MapDefaults.locationSettings.copy( + locationEnabled = state.hasLocationPermission, + ), + ) + Icon( + resourceId = DesignSystemR.drawable.pin, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.align { size, space, _ -> + // Center bottom edge of pin (i.e. its arrow) to center of screen + IntOffset( + x = (space.width - size.width) / 2, + y = (space.height / 2) - size.height, + ) + } + ) + FloatingActionButton( + onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 72.dp + navBarPadding), + ) { + when (state.mode) { + SendLocationState.Mode.PinLocation -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null) + SendLocationState.Mode.SenderLocation -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null) + } + } + } + } +} + +@DayNightPreviews +@Composable +fun SendLocationViewPreview( + @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState +) = ElementPreview { + SendLocationView( + state = state, + navigateUp = {}, + ) +} + +@Composable +private fun PermissionRationaleDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), + onSubmitClicked = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} + +@Composable +private fun PermissionDeniedDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_auth_android, appName), + onSubmitClicked = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt new file mode 100644 index 0000000000..d93b15e5c5 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LocationActions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import io.element.android.features.location.api.Location + +interface LocationActions { + fun share(location: Location, label: String?) + fun openSettings() +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt new file mode 100644 index 0000000000..7dc1fc02f3 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class ShowLocationEntryPointImpl @Inject constructor() : ShowLocationEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ShowLocationEntryPoint.Inputs): Node { + return parentNode.createNode<ShowLocationNode>(buildContext, listOf(inputs)) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt new file mode 100644 index 0000000000..b725ec6db7 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +sealed interface ShowLocationEvents { + object Share : ShowLocationEvents + data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt new file mode 100644 index 0000000000..24094b03ca --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +class ShowLocationNode @AssistedInject constructor( + presenterFactory: ShowLocationPresenter.Factory, + analyticsService: AnalyticsService, + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView)) + } + ) + } + + private val inputs: ShowLocationEntryPoint.Inputs = inputs() + private val presenter = presenterFactory.create(inputs.location, inputs.description) + + @Composable + override fun View(modifier: Modifier) { + ShowLocationView( + state = presenter.present(), + modifier = modifier, + onBackPressed = ::navigateUp + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt new file mode 100644 index 0000000000..3ac5d90bb6 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.MapDefaults +import io.element.android.features.location.impl.permissions.PermissionsPresenter +import io.element.android.features.location.impl.permissions.PermissionsState +import io.element.android.libraries.architecture.Presenter + +class ShowLocationPresenter @AssistedInject constructor( + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val actions: LocationActions, + @Assisted private val location: Location, + @Assisted private val description: String? +) : Presenter<ShowLocationState> { + + @AssistedFactory + interface Factory { + fun create(location: Location, description: String?): ShowLocationPresenter + } + + private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) + + @Composable + override fun present(): ShowLocationState { + val permissionsState: PermissionsState = permissionsPresenter.present() + var isTrackMyLocation by remember { mutableStateOf(false) } + + fun handleEvents(event: ShowLocationEvents) { + when (event) { + ShowLocationEvents.Share -> actions.share(location, description) + is ShowLocationEvents.TrackMyLocation -> isTrackMyLocation = event.enabled + } + } + + return ShowLocationState( + location = location, + description = description, + hasLocationPermission = permissionsState.isAnyGranted, + isTrackMyLocation = isTrackMyLocation, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt new file mode 100644 index 0000000000..c567dd3c94 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import io.element.android.features.location.api.Location + +data class ShowLocationState( + val location: Location, + val description: String?, + val hasLocationPermission: Boolean, + val isTrackMyLocation: Boolean, + val eventSink: (ShowLocationEvents) -> Unit, +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt new file mode 100644 index 0000000000..1865c6b9a4 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.api.Location + +class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> { + override val values: Sequence<ShowLocationState> + get() = sequenceOf( + ShowLocationState( + Location(1.23, 2.34, 4f), + description = null, + hasLocationPermission = false, + isTrackMyLocation = false, + eventSink = {}, + ), + ShowLocationState( + Location(1.23, 2.34, 4f), + description = null, + hasLocationPermission = true, + isTrackMyLocation = false, + eventSink = {}, + ), + ShowLocationState( + Location(1.23, 2.34, 4f), + description = null, + hasLocationPermission = true, + isTrackMyLocation = true, + eventSink = {}, + ), + ShowLocationState( + Location(1.23, 2.34, 4f), + description = "My favourite place!", + hasLocationPermission = false, + isTrackMyLocation = false, + eventSink = {}, + ), + ShowLocationState( + Location(1.23, 2.34, 4f), + description = "For some reason I decided to write a small essay in the location description. " + + "It is so long that it will wrap onto more than two lines!", + hasLocationPermission = false, + isTrackMyLocation = false, + eventSink = {}, + ), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt new file mode 100644 index 0000000000..7726bbf9cc --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.geometry.LatLng +import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.MapDefaults +import io.element.android.features.location.impl.send.SendLocationState +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.maplibre.compose.CameraMode +import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason +import io.element.android.libraries.maplibre.compose.IconAnchor +import io.element.android.libraries.maplibre.compose.MapboxMap +import io.element.android.libraries.maplibre.compose.Symbol +import io.element.android.libraries.maplibre.compose.rememberCameraPositionState +import io.element.android.libraries.maplibre.compose.rememberSymbolState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.theme.compound.generated.TypographyTokens +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableMap +import io.element.android.libraries.designsystem.R as DesignSystemR + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ShowLocationView( + state: ShowLocationState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.Builder() + .target(LatLng(state.location.lat, state.location.lon)) + .zoom(MapDefaults.DEFAULT_ZOOM) + .build() + } + + LaunchedEffect(state.isTrackMyLocation) { + when (state.isTrackMyLocation) { + false -> cameraPositionState.cameraMode = CameraMode.NONE + true -> { + cameraPositionState.position = CameraPosition.Builder() + .zoom(MapDefaults.DEFAULT_ZOOM) + .build() + cameraPositionState.cameraMode = CameraMode.TRACKING + } + } + } + + LaunchedEffect(cameraPositionState.isMoving) { + if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { + state.eventSink(ShowLocationEvents.TrackMyLocation(false)) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(CommonStrings.screen_view_location_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { + BackButton(onClick = onBackPressed) + }, + actions = { + IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) { + Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share)) + } + } + ) + }, + floatingActionButton = { + if (state.hasLocationPermission) { + FloatingActionButton( + onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, + ) { + when (state.isTrackMyLocation) { + false -> Icon(imageVector = Icons.Default.LocationSearching, contentDescription = null) + true -> Icon(imageVector = Icons.Default.MyLocation, contentDescription = null) + } + } + } + }, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), + ) { + state.description?.let { + Text( + text = it, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TypographyTokens.fontBodyMdRegular, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) + } + + MapboxMap( + styleUri = rememberTileStyleUrl(), + modifier = Modifier.fillMaxSize(), + images = mapOf(PIN_ID to DesignSystemR.drawable.pin).toImmutableMap(), + cameraPositionState = cameraPositionState, + uiSettings = MapDefaults.uiSettings, + symbolManagerSettings = MapDefaults.symbolManagerSettings, + locationSettings = MapDefaults.locationSettings.copy( + locationEnabled = state.hasLocationPermission, + ), + ) { + Symbol( + iconId = PIN_ID, + state = rememberSymbolState( + position = LatLng(state.location.lat, state.location.lon) + ), + iconAnchor = IconAnchor.BOTTOM, + ) + } + } + } +} + +@Preview +@Composable +internal fun ShowLocationViewLightPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun ShowLocationViewDarkPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ShowLocationState) { + ShowLocationView( + state = state, + onBackPressed = {}, + ) +} + +private const val PIN_ID = "pin" + diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt new file mode 100644 index 0000000000..a18e4cf2bf --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/permissions/PermissionsPresenterFake.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.permissions + +import androidx.compose.runtime.Composable + +class PermissionsPresenterFake : PermissionsPresenter { + + val events = mutableListOf<PermissionsEvents>() + + private fun handleEvent(event: PermissionsEvents) { + events += event + } + + private var state = PermissionsState(eventSink = ::handleEvent) + set(value) { + field = value.copy(eventSink = ::handleEvent) + } + + fun givenState(state: PermissionsState) { + this.state = state + } + + @Composable + override fun present(): PermissionsState = state +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt new file mode 100644 index 0000000000..45c99b556a --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -0,0 +1,461 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.send + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.permissions.PermissionsEvents +import io.element.android.features.location.impl.permissions.PermissionsPresenter +import io.element.android.features.location.impl.permissions.PermissionsPresenterFake +import io.element.android.features.location.impl.permissions.PermissionsState +import io.element.android.features.location.impl.show.FakeLocationActions +import io.element.android.features.messages.test.MessageComposerContextFake +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.SendLocationInvocation +import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SendLocationPresenterTest { + + private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakeMatrixRoom = FakeMatrixRoom() + private val fakeAnalyticsService = FakeAnalyticsService() + private val messageComposerContextFake = MessageComposerContextFake() + private val fakeLocationActions = FakeLocationActions() + private val fakeSystemClock = SystemClock { 0L } + private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permissions: List<String>): PermissionsPresenter = permissionsPresenterFake + }, + room = fakeMatrixRoom, + analyticsService = fakeAnalyticsService, + messageComposerContext = messageComposerContextFake, + locationActions = fakeLocationActions, + systemClock = fakeSystemClock, + buildMeta = fakeBuildMeta, + ) + + @Test + fun `initial state with permissions granted`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + + val initialState = awaitItem() + Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true) + + // Swipe the map to switch mode + initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true) + } + } + + @Test + fun `initial state with permissions partially granted`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.SomeGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + + val initialState = awaitItem() + Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true) + + // Swipe the map to switch mode + initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(true) + } + } + + @Test + fun `initial state with permissions denied`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false) + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false) + } + } + + @Test + fun `initial state with permissions denied once`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false) + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false) + } + } + + @Test + fun `rationale dialog dismiss`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false) + + // Dismiss the dialog + myLocationState.eventSink(SendLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false) + } + } + + @Test + fun `rationale dialog continue`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false) + + // Continue the dialog sends permission request to the permissions presenter + myLocationState.eventSink(SendLocationEvents.RequestPermissions) + Truth.assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + } + } + + @Test + fun `permission denied dialog dismiss`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + Truth.assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) + Truth.assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(myLocationState.hasLocationPermission).isEqualTo(false) + + // Dismiss the dialog + myLocationState.eventSink(SendLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + Truth.assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + Truth.assertThat(dialogDismissedState.hasLocationPermission).isEqualTo(false) + } + } + + @Test + fun `share sender location`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Send location + initialState.eventSink( + SendLocationEvents.SendLocation( + cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + lat = 0.0, + lon = 1.0, + zoom = 2.0, + ), + location = Location( + lat = 3.0, + lon = 4.0, + accuracy = 5.0f, + ) + ) + ) + + delay(1) // Wait for the coroutine to finish + + Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1) + Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo( + SendLocationInvocation( + body = "Location was shared at geo:3.0,4.0;u=5.0 as of 1970-01-01T00:00:00Z", + geoUri = "geo:3.0,4.0;u=5.0", + description = null, + zoomLevel = 15, + assetType = AssetType.SENDER + ) + ) + + Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isLocation = true, + isReply = false, + locationType = Composer.LocationType.MyLocation, + ) + ) + } + } + + @Test + fun `share pin location`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Send location + initialState.eventSink( + SendLocationEvents.SendLocation( + cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + lat = 0.0, + lon = 1.0, + zoom = 2.0, + ), + location = Location( + lat = 3.0, + lon = 4.0, + accuracy = 5.0f, + ) + ) + ) + + delay(1) // Wait for the coroutine to finish + + Truth.assertThat(fakeMatrixRoom.sentLocations.size).isEqualTo(1) + Truth.assertThat(fakeMatrixRoom.sentLocations.last()).isEqualTo( + SendLocationInvocation( + body = "Location was shared at geo:0.0,1.0 as of 1970-01-01T00:00:00Z", + geoUri = "geo:0.0,1.0", + description = null, + zoomLevel = 15, + assetType = AssetType.PIN + ) + ) + + Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isLocation = true, + isReply = false, + locationType = Composer.LocationType.PinDrop, + ) + ) + } + } + + @Test + fun `composer context passes through analytics`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + messageComposerContextFake.apply { + composerMode = MessageComposerMode.Edit( + eventId = null, defaultContent = "", transactionId = null + ) + } + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Send location + initialState.eventSink( + SendLocationEvents.SendLocation( + cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + lat = 0.0, + lon = 1.0, + zoom = 2.0, + ), + location = null + ) + ) + + delay(1) // Wait for the coroutine to finish + + Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + Truth.assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = true, + isLocation = true, + isReply = false, + locationType = Composer.LocationType.PinDrop, + ) + ) + } + } + + @Test + fun `open settings activity`() = runTest { + permissionsPresenterFake.givenState( + PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + messageComposerContextFake.apply { + composerMode = MessageComposerMode.Edit( + eventId = null, defaultContent = "", transactionId = null + ) + } + + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val dialogShownState = awaitItem() + + // Open settings + dialogShownState.eventSink(SendLocationEvents.OpenAppSettings) + val settingsOpenedState = awaitItem() + + Truth.assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + Truth.assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `application name is in state`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.appName).isEqualTo("app name") + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt new file mode 100644 index 0000000000..29c0ba4d58 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/AndroidLocationActionsTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.buildUrl +import org.junit.Test +import java.net.URLEncoder + +internal class AndroidLocationActionsTest { + + // We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests + private fun urlEncoder(input: String) = URLEncoder.encode(input, "US-ASCII") + + @Test + fun `buildUrl - truncates excessive decimals to 6dp`() { + val location = Location( + lat = 1.234567890123, + lon = 123.456789012345, + accuracy = 0f + ) + + val actual = buildUrl(location, null, ::urlEncoder) + val expected = "geo:0,0?q=1.234568,123.456789" + + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `buildUrl - appends label if set`() { + val location = Location( + lat = 1.000001, + lon = 2.000001, + accuracy = 0f + ) + + val actual = buildUrl(location, "point", ::urlEncoder) + val expected = "geo:0,0?q=1.000001,2.000001 (point)" + + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `buildUrl - URL encodes label`() { + val location = Location( + lat = 1.000001, + lon = 2.000001, + accuracy = 0f + ) + + val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder) + val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)" + + assertThat(actual).isEqualTo(expected) + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt new file mode 100644 index 0000000000..c54aab6f28 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/FakeLocationActions.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import io.element.android.features.location.api.Location + +class FakeLocationActions : LocationActions { + + var sharedLocation: Location? = null + private set + + var sharedLabel: String? = null + private set + + var openSettingsInvocationsCount = 0 + private set + + override fun share(location: Location, label: String?) { + sharedLocation = location + sharedLabel = label + } + + override fun openSettings() { + openSettingsInvocationsCount++ + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt new file mode 100644 index 0000000000..47f0e2ccea --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.location.impl.show + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.permissions.PermissionsPresenter +import io.element.android.features.location.impl.permissions.PermissionsPresenterFake +import io.element.android.features.location.impl.permissions.PermissionsState +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ShowLocationPresenterTest { + + private val permissionsPresenterFake = PermissionsPresenterFake() + private val actions = FakeLocationActions() + private val location = Location(1.23, 4.56, 7.8f) + private val presenter = ShowLocationPresenter( + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permissions: List<String>): PermissionsPresenter = permissionsPresenterFake + }, + actions, + location, + A_DESCRIPTION, + ) + + @Test + fun `emits initial state with no location permission`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.location).isEqualTo(location) + Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(false) + Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false) + } + } + + @Test + fun `emits initial state with location permission`() = runTest { + permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.location).isEqualTo(location) + Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true) + Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false) + } + } + + @Test + fun `emits initial state with partial location permission`() = runTest { + permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.location).isEqualTo(location) + Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true) + Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false) + } + } + + @Test + fun `uses action to share location`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ShowLocationEvents.Share) + + Truth.assertThat(actions.sharedLocation).isEqualTo(location) + Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION) + } + } + + @Test + fun `centers on user location`() = runTest { + permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.hasLocationPermission).isEqualTo(true) + Truth.assertThat(initialState.isTrackMyLocation).isEqualTo(false) + + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val trackMyLocationState = awaitItem() + + delay(1) + + Truth.assertThat(trackMyLocationState.hasLocationPermission).isEqualTo(true) + Truth.assertThat(trackMyLocationState.isTrackMyLocation).isEqualTo(true) + } + } + + companion object { + private const val A_DESCRIPTION = "My happy place" + } + +} diff --git a/features/login/api/build.gradle.kts b/features/login/api/build.gradle.kts new file mode 100644 index 0000000000..5b7bddb15f --- /dev/null +++ b/features/login/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.login.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt new file mode 100644 index 0000000000..07a546192d --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface LoginEntryPoint : FeatureEntryPoint { + data class Params( + val isAccountCreation: Boolean, + ) + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun build(): Node + } +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt new file mode 100644 index 0000000000..3a4cc54563 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginUserStory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.api + +import kotlinx.coroutines.flow.StateFlow + +interface LoginUserStory { + val loginFlowIsDone: StateFlow<Boolean> +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt new file mode 100644 index 0000000000..6e90a390c4 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcAction.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.api.oidc + +sealed interface OidcAction { + object GoBack : OidcAction + data class Success(val url: String) : OidcAction +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt new file mode 100644 index 0000000000..004e7c8a51 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcActionFlow.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.api.oidc + +interface OidcActionFlow { + fun post(oidcAction: OidcAction) +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt new file mode 100644 index 0000000000..a6ecf26fca --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/oidc/OidcIntentResolver.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.api.oidc + +import android.content.Intent + +interface OidcIntentResolver { + fun resolve(intent: Intent): OidcAction? +} diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts new file mode 100644 index 0000000000..f9bbb390e6 --- /dev/null +++ b/features/login/impl/build.gradle.kts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") + kotlin("plugin.serialization") version "1.8.22" +} + +android { + namespace = "io.element.android.features.login.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.network) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(libs.androidx.browser) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) + api(projects.features.login.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..172e8645c1 --- /dev/null +++ b/features/login/impl/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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"> + + <queries> + <!-- To open URL in CustomTab (prefetch, etc.). It makes CustomTabsClient.getPackageName() work + see https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs --> + <intent> + <action android:name="android.support.customtabs.action.CustomTabsService" /> + </intent> + </queries> + +</manifest> diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt new file mode 100644 index 0000000000..a4290825fb --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LoginEntryPoint.NodeBuilder { + val plugins = ArrayList<Plugin>() + + return object : LoginEntryPoint.NodeBuilder { + + override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder { + plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation) + return this + } + + override fun build(): Node { + return parentNode.createNode<LoginFlowNode>(buildContext, plugins) + } + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt new file mode 100644 index 0000000000..26b00068bc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginUserStory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.api.LoginUserStory +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultLoginUserStory @Inject constructor() : LoginUserStory { + // True by default, will be set to false when the login user story is started, and set to true again once it's done. + override val loginFlowIsDone: MutableStateFlow<Boolean> = MutableStateFlow(true) + + fun setLoginFlowIsDone(value: Boolean) { + loginFlowIsDone.value = value + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt new file mode 100644 index 0000000000..fe47fb1b67 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl + +import android.app.Activity +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.singleTop +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker +import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler +import io.element.android.features.login.impl.oidc.webview.OidcNode +import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode +import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode +import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode +import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.theme.ElementTheme +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +class LoginFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val customTabAvailabilityChecker: CustomTabAvailabilityChecker, + private val customTabHandler: CustomTabHandler, + private val accountProviderDataSource: AccountProviderDataSource, + private val defaultLoginUserStory: DefaultLoginUserStory, +) : BackstackNode<LoginFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.ConfirmAccountProvider, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + private var activity: Activity? = null + private var darkTheme: Boolean = false + + data class Inputs( + val isAccountCreation: Boolean, + ) : NodeInputs + + private val inputs: Inputs = inputs() + + override fun onBuilt() { + super.onBuilt() + defaultLoginUserStory.setLoginFlowIsDone(false) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + object ConfirmAccountProvider : NavTarget + + @Parcelize + object ChangeAccountProvider : NavTarget + + @Parcelize + object SearchAccountProvider : NavTarget + + @Parcelize + object LoginPassword : NavTarget + + @Parcelize + data class WaitList(val loginFormState: LoginFormState) : NavTarget + + @Parcelize + data class OidcView(val oidcDetails: OidcDetails) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.ConfirmAccountProvider -> { + val inputs = ConfirmAccountProviderNode.Inputs( + isAccountCreation = inputs.isAccountCreation + ) + val callback = object : ConfirmAccountProviderNode.Callback { + override fun onOidcDetails(oidcDetails: OidcDetails) { + if (customTabAvailabilityChecker.supportCustomTab()) { + // In this case open a Chrome Custom tab + activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) } + } else { + // Fallback to WebView mode + backstack.push(NavTarget.OidcView(oidcDetails)) + } + } + + override fun onLoginPasswordNeeded() { + backstack.push(NavTarget.LoginPassword) + } + + override fun onChangeAccountProvider() { + backstack.push(NavTarget.ChangeAccountProvider) + } + } + createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback)) + } + NavTarget.ChangeAccountProvider -> { + val callback = object : ChangeAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.ConfirmAccountProvider) + } + + override fun onOtherClicked() { + backstack.push(NavTarget.SearchAccountProvider) + } + } + + createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback)) + } + NavTarget.SearchAccountProvider -> { + val callback = object : SearchAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + backstack.singleTop(NavTarget.ConfirmAccountProvider) + } + } + + createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback)) + } + NavTarget.LoginPassword -> { + val callback = object : LoginPasswordNode.Callback { + override fun onWaitListError(loginFormState: LoginFormState) { + backstack.newRoot(NavTarget.WaitList(loginFormState)) + } + } + createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback)) + } + is NavTarget.OidcView -> { + val input = OidcNode.Inputs(navTarget.oidcDetails) + createNode<OidcNode>(buildContext, plugins = listOf(input)) + } + is NavTarget.WaitList -> { + val inputs = WaitListNode.Inputs( + loginFormState = navTarget.loginFormState, + ) + val callback = object : WaitListNode.Callback { + override fun onCancelClicked() { + navigateUp() + } + } + createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + activity = LocalContext.current as? Activity + darkTheme = !ElementTheme.isLightTheme + DisposableEffect(Unit) { + onDispose { + activity = null + accountProviderDataSource.reset() + } + } + Children( + navModel = backstack, + modifier = modifier, + // Animate transition to change server screen + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt new file mode 100644 index 0000000000..b6aea81951 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.accountprovider + +data class AccountProvider constructor( + val title: String, + val subtitle: String? = null, + val isPublic: Boolean = false, + val isMatrixOrg: Boolean = false, + val isValid: Boolean = false, + val supportSlidingSync: Boolean = false, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt new file mode 100644 index 0000000000..ea541285df --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.accountprovider + +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +class AccountProviderDataSource @Inject constructor( +) { + private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow( + defaultAccountProvider + ) + + fun flow(): StateFlow<AccountProvider> { + return accountProvider.asStateFlow() + } + + fun reset() { + accountProvider.tryEmit(defaultAccountProvider) + } + + fun userSelection(data: AccountProvider) { + accountProvider.tryEmit(data) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt new file mode 100644 index 0000000000..71e1abd591 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.accountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> { + override val values: Sequence<AccountProvider> + get() = sequenceOf( + anAccountProvider(), + anAccountProvider().copy(subtitle = null), + anAccountProvider().copy(subtitle = null, title = "no.sliding.sync", supportSlidingSync = false), + anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false, supportSlidingSync = false), + anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false), + // Add other state here + ) +} + +fun anAccountProvider() = AccountProvider( + title = "matrix.org", + subtitle = "Matrix.org is an open network for secure, decentralized communication.", + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt new file mode 100644 index 0000000000..3362beac40 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.accountprovider + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun AccountProviderView( + item: AccountProvider, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Column(modifier = modifier + .fillMaxWidth() + .clickable { onClick() }) { + Divider() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.isMatrixOrg) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + resourceId = R.drawable.ic_matrix, + tint = Color.Unspecified, + ) + } else { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = Icons.Filled.Search, + tint = MaterialTheme.colorScheme.primary, + ) + } + Text( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), + text = item.title, + style = ElementTheme.typography.fontBodyLgMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (item.isPublic) { + Icon( + modifier = Modifier + .padding(start = 10.dp) + .size(16.dp), + resourceId = R.drawable.ic_public, + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + if (item.subtitle != null) { + Text( + modifier = Modifier + .padding(start = 46.dp, bottom = 12.dp, end = 26.dp), + text = item.subtitle, + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } +} + +@Preview +@Composable +fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = + ElementPreviewLight { ContentToPreview(item) } + +@Preview +@Composable +fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = + ElementPreviewDark { ContentToPreview(item) } + +@Composable +private fun ContentToPreview(item: AccountProvider) { + AccountProviderView( + item = item, + onClick = { } + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt new file mode 100644 index 0000000000..cd1cb7b4ce --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import io.element.android.features.login.impl.accountprovider.AccountProvider + +sealed interface ChangeServerEvents { + data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents + object ClearError : ChangeServerEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt new file mode 100644 index 0000000000..ef9f9e2441 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.net.URL +import javax.inject.Inject + +class ChangeServerPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, +) : Presenter<ChangeServerState> { + + @Composable + override fun present(): ChangeServerState { + val localCoroutineScope = rememberCoroutineScope() + + val changeServerAction: MutableState<Async<Unit>> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: ChangeServerEvents) { + when (event) { + is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction) + ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized + } + } + + return ChangeServerState( + changeServerAction = changeServerAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.changeServer( + data: AccountProvider, + changeServerAction: MutableState<Async<Unit>>, + ) = launch { + suspend { + val domain = tryOrNull { URL(data.title) }?.host ?: data.title + authenticationService.setHomeserver(domain).map { + authenticationService.getHomeserverDetails().value!! + // Valid, remember user choice + accountProviderDataSource.userSelection(data) + }.getOrThrow() + }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt new file mode 100644 index 0000000000..e49fa1f2fe --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import io.element.android.libraries.architecture.Async + +data class ChangeServerState( + val changeServerAction: Async<Unit>, + val eventSink: (ChangeServerEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt new file mode 100644 index 0000000000..90c2bff455 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> { + override val values: Sequence<ChangeServerState> + get() = sequenceOf( + aChangeServerState(), + ) +} + +fun aChangeServerState() = ChangeServerState( + changeServerAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt new file mode 100644 index 0000000000..f9e9624503 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun ChangeServerView( + state: ChangeServerState, + onLearnMoreClicked: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + when (state.changeServerAction) { + is Async.Failure -> { + when (val error = state.changeServerAction.error) { + is ChangeServerError.Error -> { + ErrorDialog( + modifier = modifier, + content = error.message(), + onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + } + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog( + modifier = modifier, + onLearnMoreClicked = { + onLearnMoreClicked() + eventSink.invoke(ChangeServerEvents.ClearError) + }, onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + }) + } + } + } + is Async.Loading -> ProgressDialog() + is Async.Success -> LaunchedEffect(state.changeServerAction) { + onDone() + } + Async.Uninitialized -> Unit + } +} + +@Preview +@Composable +fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ChangeServerState) { + ChangeServerView( + state = state, + onLearnMoreClicked = {}, + onDone = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt new file mode 100644 index 0000000000..5beb14b0b4 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun SlidingSyncNotSupportedDialog( + onLearnMoreClicked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ConfirmationDialog( + modifier = modifier, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_learn_more), + onSubmitClicked = onLearnMoreClicked, + onCancelClicked = onDismiss, + emphasizeSubmitButton = true, + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt new file mode 100644 index 0000000000..444ea3d3f2 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.error + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.login.impl.R +import io.element.android.libraries.matrix.api.auth.AuthenticationException + +sealed class ChangeServerError : Throwable() { + data class Error(@StringRes val messageId: Int) : ChangeServerError() { + @Composable + fun message(): String = stringResource(messageId) + } + object SlidingSyncAlert : ChangeServerError() + + companion object { + fun from(error: Throwable): ChangeServerError = when (error) { + is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert + else -> Error(R.string.screen_change_server_error_invalid_homeserver) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt new file mode 100644 index 0000000000..15d5e616de --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.login.impl.error + +import androidx.annotation.StringRes +import io.element.android.features.login.impl.R +import io.element.android.libraries.matrix.api.auth.AuthErrorCode +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import io.element.android.libraries.matrix.api.auth.errorCode +import io.element.android.libraries.ui.strings.CommonStrings + +@StringRes +fun loginError( + throwable: Throwable +): Int { + val authException = throwable as? AuthenticationException ?: return CommonStrings.error_unknown + return when (authException.errorCode) { + AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials + AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account + AuthErrorCode.UNKNOWN -> CommonStrings.error_unknown + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt new file mode 100644 index 0000000000..99060f3464 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/WaitListError.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.error + +import io.element.android.libraries.core.bool.orFalse + +fun Throwable.isWaitListError(): Boolean { + return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/CustomTabAvailabilityChecker.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/CustomTabAvailabilityChecker.kt new file mode 100644 index 0000000000..424e9f13bc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/CustomTabAvailabilityChecker.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc + +import android.content.Context +import androidx.browser.customtabs.CustomTabsClient +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +class CustomTabAvailabilityChecker @Inject constructor( + @ApplicationContext private val context: Context, +) { + /** + * Return true if the device supports Custom tab, i.e. there is an third party app with + * CustomTab support (ex: Chrome, Firefox, etc.). + */ + fun supportCustomTab(): Boolean { + val packageName = CustomTabsClient.getPackageName(context, null) + return packageName != null + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/DefaultOidcIntentResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/DefaultOidcIntentResolver.kt new file mode 100644 index 0000000000..8b6844e0f3 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/DefaultOidcIntentResolver.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc + +import android.content.Intent +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.api.oidc.OidcIntentResolver +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultOidcIntentResolver @Inject constructor( + private val oidcUrlParser: OidcUrlParser, +) : OidcIntentResolver { + override fun resolve(intent: Intent): OidcAction? { + return oidcUrlParser.parse(intent.dataString.orEmpty()) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParser.kt new file mode 100644 index 0000000000..487df70253 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParser.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc + +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.libraries.matrix.api.auth.OidcConfig +import javax.inject.Inject + +/** + * Simple parser for oidc url interception. + * TODO Find documentation about the format. + */ +class OidcUrlParser @Inject constructor() { + + // When user press button "Cancel", we get the url: + // `io.element:/callback?error=access_denied&state=IFF1UETGye2ZA8pO` + // On success, we get: + // `io.element:/callback?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB` + /** + * Return a OidcAction, or null if the url is not a OidcUrl. + */ + fun parse(url: String): OidcAction? { + if (url.startsWith(OidcConfig.redirectUri).not()) return null + if (url.contains("error=access_denied")) return OidcAction.GoBack + if (url.contains("code=")) return OidcAction.Success(url) + + // Other case not supported, let's crash the app for now + error("Not supported: $url") + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt new file mode 100644 index 0000000000..48c674e0a0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.customtab + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +class CustomTabHandler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private var customTabsSession: CustomTabsSession? = null + private var customTabsClient: CustomTabsClient? = null + private var customTabsServiceConnection: CustomTabsServiceConnection? = null + + fun prepareCustomTab(url: String) { + val packageName = CustomTabsClient.getPackageName(context, null) + + // packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device + if (packageName != null) { + customTabsServiceConnection = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + customTabsClient = client + .also { it.warmup(0L) } + prefetchUrl(url) + } + + override fun onServiceDisconnected(name: ComponentName?) { + } + } + .also { + CustomTabsClient.bindCustomTabsService( + context, + // Despite the API, packageName cannot be null + packageName, + it + ) + } + } + } + + private fun prefetchUrl(url: String) { + if (customTabsSession == null) { + customTabsSession = customTabsClient?.newSession(null) + } + + customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null) + } + + fun disposeCustomTab() { + customTabsServiceConnection?.let { context.unbindService(it) } + customTabsServiceConnection = null + } + + fun open(activity: Activity, darkTheme: Boolean, url: String) { + activity.openUrlInChromeCustomTab(customTabsSession, darkTheme, url) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/DefaultOidcActionFlow.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/DefaultOidcActionFlow.kt new file mode 100644 index 0000000000..17dfa8418f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/DefaultOidcActionFlow.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.customtab + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.features.login.api.oidc.OidcActionFlow +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultOidcActionFlow @Inject constructor() : OidcActionFlow { + private val mutableStateFlow = MutableStateFlow<OidcAction?>(null) + + override fun post(oidcAction: OidcAction) { + mutableStateFlow.value = oidcAction + } + + suspend fun collect(collector: FlowCollector<OidcAction?>) { + mutableStateFlow.collect(collector) + } + + fun reset() { + mutableStateFlow.value = null + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcEvents.kt new file mode 100644 index 0000000000..6265cfc85a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import io.element.android.features.login.api.oidc.OidcAction + +sealed interface OidcEvents { + object Cancel : OidcEvents + data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents + object ClearError : OidcEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcNode.kt new file mode 100644 index 0000000000..dd16b5e57b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcNode.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +class OidcNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: OidcPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val oidcDetails: OidcDetails, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.oidcDetails) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + OidcView( + state = state, + modifier = modifier, + onNavigateBack = ::navigateUp, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenter.kt new file mode 100644 index 0000000000..66926b3734 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenter.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.OidcDetails +import kotlinx.coroutines.launch + +class OidcPresenter @AssistedInject constructor( + @Assisted private val oidcDetails: OidcDetails, + private val authenticationService: MatrixAuthenticationService, +) : Presenter<OidcState> { + + @AssistedFactory + interface Factory { + fun create(oidcDetails: OidcDetails): OidcPresenter + } + + @Composable + override fun present(): OidcState { + var requestState: Async<Unit> by remember { + mutableStateOf(Async.Uninitialized) + } + val localCoroutineScope = rememberCoroutineScope() + + fun handleCancel() { + requestState = Async.Loading() + localCoroutineScope.launch { + authenticationService.cancelOidcLogin() + .fold( + onSuccess = { + // Then go back + requestState = Async.Success(Unit) + }, + onFailure = { + requestState = Async.Failure(it) + } + ) + } + } + + fun handleSuccess(url: String) { + requestState = Async.Loading() + localCoroutineScope.launch { + authenticationService.loginWithOidc(url) + .onFailure { + requestState = Async.Failure(it) + } + // On success, the node tree will be updated, there is nothing to do + } + } + + fun handleAction(action: OidcAction) { + when (action) { + OidcAction.GoBack -> handleCancel() + is OidcAction.Success -> handleSuccess(action.url) + } + } + + fun handleEvents(event: OidcEvents) { + when (event) { + OidcEvents.Cancel -> handleCancel() + is OidcEvents.OidcActionEvent -> handleAction(event.oidcAction) + OidcEvents.ClearError -> requestState = Async.Uninitialized + } + } + + return OidcState( + oidcDetails = oidcDetails, + requestState = requestState, + eventSink = ::handleEvents + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcState.kt new file mode 100644 index 0000000000..fc9507a89d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.OidcDetails + +data class OidcState( + val oidcDetails: OidcDetails, + val requestState: Async<Unit>, + val eventSink: (OidcEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcStateProvider.kt new file mode 100644 index 0000000000..80878cf8f8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.OidcDetails + +open class OidcStateProvider : PreviewParameterProvider<OidcState> { + override val values: Sequence<OidcState> + get() = sequenceOf( + aOidcState(), + aOidcState().copy(requestState = Async.Loading()), + ) +} + +fun aOidcState() = OidcState( + oidcDetails = aOidcDetails(), + requestState = Async.Uninitialized, + eventSink = {} +) + +fun aOidcDetails() = OidcDetails( + url = "aUrl", +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt new file mode 100644 index 0000000000..c1235b76c5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.viewinterop.AndroidView +import io.element.android.features.login.impl.oidc.OidcUrlParser +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator + +@Composable +fun OidcView( + state: OidcState, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val oidcUrlParser = remember { OidcUrlParser() } + var webView by remember { mutableStateOf<WebView?>(null) } + fun shouldOverrideUrl(url: String): Boolean { + val action = oidcUrlParser.parse(url) + if (action != null) { + state.eventSink.invoke(OidcEvents.OidcActionEvent(action)) + return true + } + return false + } + + val oidcWebViewClient = remember { + OidcWebViewClient(::shouldOverrideUrl) + } + + BackHandler { + if (webView?.canGoBack().orFalse()) { + webView?.goBack() + } else { + // To properly cancel Oidc login + state.eventSink.invoke(OidcEvents.Cancel) + } + } + + Box(modifier = modifier.statusBarsPadding()) { + AndroidView( + factory = { context -> + WebView(context).apply { + webViewClient = oidcWebViewClient + loadUrl(state.oidcDetails.url) + }.also { + webView = it + } + } + ) + + when (state.requestState) { + Async.Uninitialized -> Unit + is Async.Failure -> { + ErrorDialog( + content = state.requestState.error.toString(), + onDismiss = { state.eventSink(OidcEvents.ClearError) } + ) + } + is Async.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + is Async.Success -> onNavigateBack() + } + } +} + +@Preview +@Composable +fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: OidcState) { + OidcView( + state = state, + onNavigateBack = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt new file mode 100644 index 0000000000..0da6e16ee0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +import android.annotation.TargetApi +import android.os.Build +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class OidcWebViewClient( + private val eventListener: WebViewEventListener, +) : WebViewClient() { + @TargetApi(Build.VERSION_CODES.N) + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return shouldOverrideUrl(request.url.toString()) + } + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + return shouldOverrideUrl(url) + } + + private fun shouldOverrideUrl(url: String): Boolean { + // Timber.d("shouldOverrideUrl: $url") + return eventListener.shouldOverrideUrlLoading(url) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/WebViewEventListener.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/WebViewEventListener.kt new file mode 100644 index 0000000000..446754aced --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/WebViewEventListener.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc.webview + +fun interface WebViewEventListener { + /** + * Triggered when a Webview loads an url. + * + * @param url The url about to be rendered. + * @return true if the method needs to manage some custom handling + */ + fun shouldOverrideUrlLoading(url: String): Boolean +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt new file mode 100644 index 0000000000..c1f0158605 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver + +data class HomeserverData constructor( + // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url + val homeserverUrl: String, + // True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid + val isWellknownValid: Boolean, + // True if a wellknown file has been found and is valid and is claiming a sliding sync Url + val supportSlidingSync: Boolean, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt new file mode 100644 index 0000000000..b1ccd8e684 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver + +import io.element.android.features.login.impl.resolver.network.WellknownRequest +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.core.uri.isValidUrl +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.util.Collections +import javax.inject.Inject + +/** + * Resolve homeserver base on search terms. + */ +class HomeserverResolver @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val wellknownRequest: WellknownRequest, +) { + + suspend fun resolve(userInput: String): Flow<List<HomeserverData>> = flow { + val flowContext = currentCoroutineContext() + val trimmedUserInput = userInput.trim() + if (trimmedUserInput.length < 4) return@flow + val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/") + val list = getUrlCandidates(candidateBase) + val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>()) + // Run all the requests in parallel + withContext(dispatchers.io) { + list.parallelMap { url -> + val wellKnown = tryOrNull { + withTimeout(5000) { + wellknownRequest.execute(url) + } + } + val isValid = wellKnown?.isValid().orFalse() + if (isValid) { + val supportSlidingSync = wellKnown?.supportSlidingSync().orFalse() + // Emit the list as soon as possible + currentList.add( + HomeserverData( + homeserverUrl = url, + isWellknownValid = true, + supportSlidingSync = supportSlidingSync + ) + ) + withContext(flowContext) { + emit(currentList.toList()) + } + } + } + } + // If list is empty, and the user has entered an URL, do not block the user. + if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) { + emit( + listOf( + HomeserverData( + homeserverUrl = trimmedUserInput, + isWellknownValid = false, + supportSlidingSync = false, + ) + ) + ) + } + } + + private fun getUrlCandidates(data: String): List<String> { + return buildList { + if (data.contains(".")) { + // TLD detected? + } else { + add("${data}.org") + add("${data}.com") + add("${data}.io") + } + // Always try what the user has entered + add(data) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt new file mode 100644 index 0000000000..7de6d26f10 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/DefaultWellknownRequest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultWellknownRequest @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : WellknownRequest { + /** + * Return the WellKnown data, if found. + * @param baseUrl for instance https://matrix.org + */ + override suspend fun execute(baseUrl: String): WellKnown { + val wellknownApi = retrofitFactory.create(baseUrl) + .create(WellknownAPI::class.java) + return wellknownApi.getWellKnown() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt new file mode 100644 index 0000000000..63b6e7d189 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnown.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +import io.element.android.libraries.core.bool.orFalse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + * <pre> + * { + * "m.homeserver": { + * "base_url": "https://matrix.org" + * }, + * "m.identity_server": { + * "base_url": "https://vector.im" + * }, + * "org.matrix.msc3575.proxy": { + * "url": "https://slidingsync.lab.matrix.org" + * } + * } + * </pre> + * . + */ +@Serializable +data class WellKnown( + @SerialName("m.homeserver") + val homeServer: WellKnownBaseConfig? = null, + + @SerialName("m.identity_server") + val identityServer: WellKnownBaseConfig? = null, + + @SerialName("org.matrix.msc3575.proxy") + val slidingSyncProxy: WellKnownSlidingSyncConfig? = null, +) { + fun isValid(): Boolean { + return homeServer?.baseURL?.isNotBlank().orFalse() + } + + fun supportSlidingSync(): Boolean { + return slidingSyncProxy?.url?.isNotBlank().orFalse() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt new file mode 100644 index 0000000000..87b86736fa --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownBaseConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery + * <pre> + * { + * "base_url": "https://element.io" + * } + * </pre> + * . + */ +@Serializable +data class WellKnownBaseConfig( + @SerialName("base_url") + val baseURL: String? = null +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt new file mode 100644 index 0000000000..98c712d9ac --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellKnownSlidingSyncConfig.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WellKnownSlidingSyncConfig( + @SerialName("url") + val url: String? = null, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt new file mode 100644 index 0000000000..04a0dfb803 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownAPI.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +import retrofit2.http.GET + +internal interface WellknownAPI { + @GET(".well-known/matrix/client") + suspend fun getWellKnown(): WellKnown +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt new file mode 100644 index 0000000000..570b621b83 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/network/WellknownRequest.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +interface WellknownRequest { + /** + * Return the WellKnown data, or throw an error if not found. + * @param baseUrl for instance https://matrix.org + */ + suspend fun execute(baseUrl: String): WellKnown +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt new file mode 100644 index 0000000000..45bf4489b5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class ChangeAccountProviderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: ChangeAccountProviderPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onDone() + fun onOtherClicked() + } + + private fun onDone() { + plugins<Callback>().forEach { it.onDone() } + } + + private fun onOtherClicked() { + plugins<Callback>().forEach { it.onOtherClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ChangeAccountProviderView( + state = state, + modifier = modifier, + onBackPressed = ::navigateUp, + onLearnMoreClicked = { openLearnMorePage(context) }, + onDone = ::onDone, + onOtherProviderClicked = ::onOtherClicked, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt new file mode 100644 index 0000000000..dfdb7dcf99 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.runtime.Composable +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +class ChangeAccountProviderPresenter @Inject constructor( + private val changeServerPresenter: ChangeServerPresenter, +) : Presenter<ChangeAccountProviderState> { + + @Composable + override fun present(): ChangeAccountProviderState { + val changeServerState = changeServerPresenter.present() + return ChangeAccountProviderState( + // Just matrix.org by default for now + accountProviders = listOf( + AccountProvider( + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, + ) + ), + changeServerState = changeServerState, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt new file mode 100644 index 0000000000..806ce5bc64 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.changeaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState + +// Do not use default value, so no member get forgotten in the presenters. +data class ChangeAccountProviderState constructor( + val accountProviders: List<AccountProvider>, + val changeServerState: ChangeServerState, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt new file mode 100644 index 0000000000..403746f227 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.changeserver.aChangeServerState + +open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> { + override val values: Sequence<ChangeAccountProviderState> + get() = sequenceOf( + aChangeAccountProviderState(), + // Add other state here + ) +} + +fun aChangeAccountProviderState() = ChangeAccountProviderState( + accountProviders = listOf( + anAccountProvider() + ), + changeServerState = aChangeServerState(), +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt new file mode 100644 index 0000000000..0f444350c9 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderView +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerView +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun ChangeAccountProviderView( + state: ChangeAccountProviderState, + onBackPressed: () -> Unit, + onLearnMoreClicked: () -> Unit, + onDone: () -> Unit, + onOtherProviderClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + Column( + modifier = Modifier + .verticalScroll(state = rememberScrollState()) + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), + iconImageVector = Icons.Filled.Home, + iconTint = MaterialTheme.colorScheme.primary, + title = stringResource(id = R.string.screen_change_account_provider_title), + subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle), + ) + + state.accountProviders.forEach { item -> + val alteredItem = if (item.isMatrixOrg) { + // Set the subtitle from the resource + item.copy( + subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle), + ) + } else { + item + } + AccountProviderView( + item = alteredItem, + onClick = { + state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem)) + } + ) + } + // Other + AccountProviderView( + item = AccountProvider( + title = stringResource(id = R.string.screen_change_account_provider_other), + ), + onClick = onOtherProviderClicked + ) + Spacer(Modifier.height(32.dp)) + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClicked = onLearnMoreClicked, + onDone = onDone, + ) + } + } +} + +@Preview +@Composable +fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ChangeAccountProviderState) { + ChangeAccountProviderView( + state = state, + onBackPressed = { }, + onLearnMoreClicked = { }, + onDone = { }, + onOtherProviderClicked = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt new file mode 100644 index 0000000000..1ba3cc3028 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +sealed interface ConfirmAccountProviderEvents { + object Continue : ConfirmAccountProviderEvents + object ClearError : ConfirmAccountProviderEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt new file mode 100644 index 0000000000..7cef986013 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +class ConfirmAccountProviderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: ConfirmAccountProviderPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val isAccountCreation: Boolean, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create( + ConfirmAccountProviderPresenter.Params( + isAccountCreation = inputs.isAccountCreation, + ) + ) + + interface Callback : Plugin { + fun onLoginPasswordNeeded() + fun onOidcDetails(oidcDetails: OidcDetails) + fun onChangeAccountProvider() + } + + private fun onOidcDetails(data: OidcDetails) { + plugins<Callback>().forEach { it.onOidcDetails(data) } + } + + private fun onLoginPasswordNeeded() { + plugins<Callback>().forEach { it.onLoginPasswordNeeded() } + } + + private fun onChangeAccountProvider() { + plugins<Callback>().forEach { it.onChangeAccountProvider() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ConfirmAccountProviderView( + state = state, + modifier = modifier, + onOidcDetails = ::onOidcDetails, + onLoginPasswordNeeded = ::onLoginPasswordNeeded, + onChange = ::onChangeAccountProvider, + onLearnMoreClicked = { openLearnMorePage(context) }, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt new file mode 100644 index 0000000000..123d3013c7 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.net.URL + +class ConfirmAccountProviderPresenter @AssistedInject constructor( + @Assisted private val params: Params, + private val accountProviderDataSource: AccountProviderDataSource, + private val authenticationService: MatrixAuthenticationService +) : Presenter<ConfirmAccountProviderState> { + + data class Params( + val isAccountCreation: Boolean, + ) + + @AssistedFactory + interface Factory { + fun create(params: Params): ConfirmAccountProviderPresenter + } + + @Composable + override fun present(): ConfirmAccountProviderState { + val accountProvider by accountProviderDataSource.flow().collectAsState() + val localCoroutineScope = rememberCoroutineScope() + + val loginFlowAction: MutableState<Async<LoginFlow>> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: ConfirmAccountProviderEvents) { + when (event) { + ConfirmAccountProviderEvents.Continue -> { + localCoroutineScope.submit(accountProvider.title, loginFlowAction) + } + ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized + } + } + + return ConfirmAccountProviderState( + accountProvider = accountProvider, + isAccountCreation = params.isAccountCreation, + loginFlow = loginFlowAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit( + homeserverUrl: String, + loginFlowAction: MutableState<Async<LoginFlow>>, + ) = launch { + suspend { + val domain = tryOrNull { URL(homeserverUrl) }?.host ?: homeserverUrl + authenticationService.setHomeserver(domain).map { + val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!! + if (matrixHomeServerDetails.supportsOidcLogin) { + // Retrieve the details right now + LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow()) + } else if (matrixHomeServerDetails.supportsPasswordLogin) { + LoginFlow.PasswordLogin + } else { + throw IllegalStateException("Unsupported login flow") + } + }.getOrThrow() + }.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt new file mode 100644 index 0000000000..a870b88c58 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.auth.OidcDetails + +// Do not use default value, so no member get forgotten in the presenters. +data class ConfirmAccountProviderState( + val accountProvider: AccountProvider, + val isAccountCreation: Boolean, + val loginFlow: Async<LoginFlow>, + val eventSink: (ConfirmAccountProviderEvents) -> Unit +) { + val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading) +} + +sealed interface LoginFlow { + object PasswordLogin : LoginFlow + data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt new file mode 100644 index 0000000000..d5f98f5716 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.Async + +open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> { + override val values: Sequence<ConfirmAccountProviderState> + get() = sequenceOf( + aConfirmAccountProviderState(), + // Add other state here + ) +} + +fun aConfirmAccountProviderState() = ConfirmAccountProviderState( + accountProvider = anAccountProvider(), + isAccountCreation = false, + loginFlow = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt new file mode 100644 index 0000000000..2bc002b3fc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag + +@Composable +fun ConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onOidcDetails: (OidcDetails) -> Unit, + onLoginPasswordNeeded: () -> Unit, + onLearnMoreClicked: () -> Unit, + onChange: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading by remember(state.loginFlow) { + derivedStateOf { + state.loginFlow is Async.Loading + } + } + val eventSink = state.eventSink + + HeaderFooterPage( + modifier = modifier, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp), + iconImageVector = Icons.Filled.AccountCircle, + title = stringResource( + id = if (state.isAccountCreation) { + R.string.screen_account_provider_signup_title + } else { + R.string.screen_account_provider_signin_title + }, + state.accountProvider.title + ), + subTitle = stringResource( + id = if (state.isAccountCreation) { + R.string.screen_account_provider_signup_subtitle + } else { + R.string.screen_account_provider_signin_subtitle + }, + ) + ) + }, + footer = { + ButtonColumnMolecule { + ButtonWithProgress( + text = stringResource(id = R.string.screen_account_provider_continue), + showProgress = isLoading, + onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + TextButton( + onClick = onChange, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginChangeServer) + ) { + Text(text = stringResource(id = R.string.screen_account_provider_change)) + } + } + } + ) { + when (state.loginFlow) { + is Async.Failure -> { + when (val error = state.loginFlow.error) { + is ChangeServerError.Error -> { + ErrorDialog( + content = error.message(), + onDismiss = { + eventSink.invoke(ConfirmAccountProviderEvents.ClearError) + } + ) + } + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog(onLearnMoreClicked = { + onLearnMoreClicked() + eventSink(ConfirmAccountProviderEvents.ClearError) + }, onDismiss = { + eventSink(ConfirmAccountProviderEvents.ClearError) + }) + } + } + } + is Async.Loading -> Unit // The Continue button shows the loading state + is Async.Success -> { + when (val loginFlowState = state.loginFlow.data) { + is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) + LoginFlow.PasswordLogin -> onLoginPasswordNeeded() + } + } + Async.Uninitialized -> Unit + } + } +} + +@Preview +@Composable +fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ConfirmAccountProviderState) { + ConfirmAccountProviderView( + state = state, + onOidcDetails = {}, + onLoginPasswordNeeded = {}, + onLearnMoreClicked = {}, + onChange = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt new file mode 100644 index 0000000000..e6f23ca418 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +sealed interface LoginPasswordEvents { + data class SetLogin(val login: String) : LoginPasswordEvents + data class SetPassword(val password: String) : LoginPasswordEvents + object Submit : LoginPasswordEvents + object ClearError : LoginPasswordEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt new file mode 100644 index 0000000000..cb5542d2eb --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class LoginPasswordNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: LoginPasswordPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onWaitListError(loginFormState: LoginFormState) + } + + private fun onWaitListError(loginFormState: LoginFormState) { + plugins<Callback>().forEach { it.onWaitListError(loginFormState) } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LoginPasswordView( + state = state, + modifier = modifier, + onBackPressed = ::navigateUp, + onWaitListError = ::onWaitListError, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt new file mode 100644 index 0000000000..b2ea5fb985 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LoginPasswordPresenter @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, + private val defaultLoginUserStory: DefaultLoginUserStory, +) : Presenter<LoginPasswordState> { + + @Composable + override fun present(): LoginPasswordState { + val localCoroutineScope = rememberCoroutineScope() + val loginAction: MutableState<Async<SessionId>> = remember { + mutableStateOf(Async.Uninitialized) + } + + val formState = rememberSaveable { + mutableStateOf(LoginFormState.Default) + } + val accountProvider by accountProviderDataSource.flow().collectAsState() + + fun handleEvents(event: LoginPasswordEvents) { + when (event) { + is LoginPasswordEvents.SetLogin -> updateFormState(formState) { + copy(login = event.login) + } + is LoginPasswordEvents.SetPassword -> updateFormState(formState) { + copy(password = event.password) + } + LoginPasswordEvents.Submit -> { + localCoroutineScope.submit(formState.value, loginAction) + } + LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized + } + } + + return LoginPasswordState( + accountProvider = accountProvider, + formState = formState.value, + loginAction = loginAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch { + loggedInState.value = Async.Loading() + authenticationService.login(formState.login.trim(), formState.password) + .onSuccess { sessionId -> + // We will not navigate to the WaitList screen, so the login user story is done + defaultLoginUserStory.setLoginFlowIsDone(true) + loggedInState.value = Async.Success(sessionId) + } + .onFailure { failure -> + loggedInState.value = Async.Failure(failure) + } + } + + private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) { + formState.value = updateLambda(formState.value) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt new file mode 100644 index 0000000000..c8fa2f4ad3 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import android.os.Parcelable +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.parcelize.Parcelize + +data class LoginPasswordState( + val accountProvider: AccountProvider, + val formState: LoginFormState, + val loginAction: Async<SessionId>, + val eventSink: (LoginPasswordEvents) -> Unit +) { + val submitEnabled: Boolean + get() = loginAction !is Async.Failure && + ((formState.login.isNotEmpty() && formState.password.isNotEmpty())) +} + +@Parcelize +data class LoginFormState( + val login: String, + val password: String +) : Parcelable { + + companion object { + val Default = LoginFormState("", "") + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt new file mode 100644 index 0000000000..b4f5a84691 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.Async + +open class LoginPasswordStateProvider : PreviewParameterProvider<LoginPasswordState> { + override val values: Sequence<LoginPasswordState> + get() = sequenceOf( + aLoginPasswordState(), + // Loading + aLoginPasswordState().copy(loginAction = Async.Loading()), + // Error + aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))), + ) +} + +fun aLoginPasswordState() = LoginPasswordState( + accountProvider = anAccountProvider(), + formState = LoginFormState.Default, + loginAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt new file mode 100644 index 0000000000..d62506ff75 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.error.isWaitListError +import io.element.android.features.login.impl.error.loginError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.components.autofill +import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LoginPasswordView( + state: LoginPasswordState, + onBackPressed: () -> Unit, + onWaitListError: (LoginFormState) -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading by remember(state.loginAction) { + derivedStateOf { + state.loginAction is Async.Loading + } + } + val focusManager = LocalFocusManager.current + + fun submit() { + // Clear focus to prevent keyboard issues with textfields + focusManager.clearFocus(force = true) + + state.eventSink(LoginPasswordEvents.Submit) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + ) + } + ) { padding -> + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(state = scrollState) + .padding(horizontal = 16.dp), + ) { + // Title + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp), + iconImageVector = Icons.Filled.AccountCircle, + title = stringResource( + id = R.string.screen_account_provider_signin_title, + state.accountProvider.title + ), + subTitle = stringResource(id = R.string.screen_login_subtitle) + ) + Spacer(Modifier.height(40.dp)) + LoginForm( + state = state, + isLoading = isLoading, + onSubmit = ::submit + ) + // Min spacing + Spacer(Modifier.height(24.dp)) + // Flexible spacing to keep the submit button at the bottom + Spacer(modifier = Modifier.weight(1f)) + // Submit + ButtonWithProgress( + text = stringResource(R.string.screen_login_submit), + showProgress = isLoading, + onClick = ::submit, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + Spacer(modifier = Modifier.height(60.dp)) + + if (state.loginAction is Async.Failure) { + when { + state.loginAction.error.isWaitListError() -> { + onWaitListError(state.formState) + } + else -> { + LoginErrorDialog(error = state.loginAction.error, onDismiss = { + state.eventSink(LoginPasswordEvents.ClearError) + }) + } + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun LoginForm( + state: LoginPasswordState, + isLoading: Boolean, + onSubmit: () -> Unit, + modifier: Modifier = Modifier +) { + var loginFieldState by textFieldState(stateValue = state.formState.login) + var passwordFieldState by textFieldState(stateValue = state.formState.password) + + val focusManager = LocalFocusManager.current + val eventSink = state.eventSink + + Column(modifier) { + Text( + text = stringResource(R.string.screen_login_form_header), + modifier = Modifier.padding(start = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular, + ) + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = loginFieldState, + readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginEmailUsername) + .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { + loginFieldState = it + eventSink(LoginPasswordEvents.SetLogin(it)) + }), + placeholder = { + Text(text = stringResource(R.string.screen_login_username_hint)) + }, + onValueChange = { + loginFieldState = it + eventSink(LoginPasswordEvents.SetLogin(it)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { + focusManager.moveFocus(FocusDirection.Down) + }), + singleLine = true, + trailingIcon = if (loginFieldState.isNotEmpty()) { + { + IconButton(onClick = { + loginFieldState = "" + }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(CommonStrings.action_clear)) + } + } + } else null, + ) + + var passwordVisible by remember { mutableStateOf(false) } + if (state.loginAction is Async.Loading) { + // Ensure password is hidden when user submits the form + passwordVisible = false + } + Spacer(Modifier.height(20.dp)) + OutlinedTextField( + value = passwordFieldState, + readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginPassword) + .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { + passwordFieldState = it + eventSink(LoginPasswordEvents.SetPassword(it)) + }), + onValueChange = { + passwordFieldState = it + eventSink(LoginPasswordEvents.SetPassword(it)) + }, + placeholder = { + Text(text = stringResource(R.string.screen_login_password_hint)) + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff + val description = + if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), + singleLine = true, + ) + } +} + +@Composable +internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { + ErrorDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(loginError(error)), + onDismiss = onDismiss + ) +} + +@Preview +@Composable +internal fun LoginPasswordViewLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: LoginPasswordState) { + LoginPasswordView( + state = state, + onBackPressed = {}, + onWaitListError = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt new file mode 100644 index 0000000000..53ee45f644 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +sealed interface SearchAccountProviderEvents { + /** + * The user has typed something, expect to get a list of matching account provider results + * in the state. + */ + data class UserInput(val input: String) : SearchAccountProviderEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt new file mode 100644 index 0000000000..7178a105f6 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class SearchAccountProviderNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: SearchAccountProviderPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onDone() + } + + private fun onDone() { + plugins<Callback>().forEach { it.onDone() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + SearchAccountProviderView( + state = state, + modifier = modifier, + onBackPressed = ::navigateUp, + onLearnMoreClicked = { openLearnMorePage(context) }, + onDone = ::onDone, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt new file mode 100644 index 0000000000..1d8271e394 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +class SearchAccountProviderPresenter @Inject constructor( + private val homeserverResolver: HomeserverResolver, + private val changeServerPresenter: ChangeServerPresenter, +) : Presenter<SearchAccountProviderState> { + + @Composable + override fun present(): SearchAccountProviderState { + var userInput by rememberSaveable { + mutableStateOf("") + } + val changeServerState = changeServerPresenter.present() + + val data: MutableState<Async<List<HomeserverData>>> = remember { + mutableStateOf(Async.Uninitialized) + } + + LaunchedEffect(userInput) { + onUserInput(userInput, data) + } + + fun handleEvents(event: SearchAccountProviderEvents) { + when (event) { + is SearchAccountProviderEvents.UserInput -> { + userInput = event.input + } + } + } + + return SearchAccountProviderState( + userInput = userInput, + userInputResult = data.value, + changeServerState = changeServerState, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<Async<List<HomeserverData>>>) = launch { + data.value = Async.Uninitialized + // Debounce + delay(300) + data.value = Async.Loading() + homeserverResolver.resolve(userInput).collect { + data.value = Async.Success(it) + } + if (data.value !is Async.Success) { + data.value = Async.Uninitialized + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt new file mode 100644 index 0000000000..15859afde1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async + +// Do not use default value, so no member get forgotten in the presenters. +data class SearchAccountProviderState( + val userInput: String, + val userInputResult: Async<List<HomeserverData>>, + val changeServerState: ChangeServerState, + val eventSink: (SearchAccountProviderEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt new file mode 100644 index 0000000000..b6ffac8bd1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.changeserver.aChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async + +open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> { + override val values: Sequence<SearchAccountProviderState> + get() = sequenceOf( + aSearchAccountProviderState(), + aSearchAccountProviderState(userInputResult = Async.Success(aHomeserverDataList())), + // Add other state here + ) +} + +fun aSearchAccountProviderState( + userInput: String = "", + userInputResult: Async<List<HomeserverData>> = Async.Uninitialized, +) = SearchAccountProviderState( + userInput = userInput, + userInputResult = userInputResult, + changeServerState = aChangeServerState(), + eventSink = {} +) + +fun aHomeserverDataList(): List<HomeserverData> { + return listOf( + aHomeserverData(isWellknownValid = true, supportSlidingSync = true), + aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true, supportSlidingSync = false), + aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false, supportSlidingSync = false), + ) +} + +fun aHomeserverData( + homeserverUrl: String = "https://matrix.org", + isWellknownValid: Boolean = true, + supportSlidingSync: Boolean = true, +): HomeserverData { + return HomeserverData( + homeserverUrl = homeserverUrl, + isWellknownValid = isWellknownValid, + supportSlidingSync = supportSlidingSync, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt new file mode 100644 index 0000000000..84c5bf4fac --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderView +import io.element.android.features.login.impl.changeserver.ChangeServerEvents +import io.element.android.features.login.impl.changeserver.ChangeServerView +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435 + */ +@Composable +fun SearchAccountProviderView( + state: SearchAccountProviderState, + onBackPressed: () -> Unit, + onLearnMoreClicked: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp), + iconImageVector = Icons.Filled.Search, + title = stringResource(id = R.string.screen_account_provider_form_title), + subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle), + ) + } + item { + // TextInput + var userInputState by textFieldState(stateValue = state.userInput) + val focusManager = LocalFocusManager.current + OutlinedTextField( + value = userInputState, + // readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.changeServerServer), + onValueChange = { + userInputState = it + eventSink(SearchAccountProviderEvents.UserInput(it)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { + focusManager.moveFocus(FocusDirection.Down) + }), + singleLine = true, + trailingIcon = if (userInputState.isNotEmpty()) { + { + IconButton(onClick = { + userInputState = "" + eventSink(SearchAccountProviderEvents.UserInput("")) + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(CommonStrings.action_clear) + ) + } + } + } else null, + supportingText = { + Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary) + } + ) + } + + when (state.userInputResult) { + is Async.Failure -> { + // Ignore errors (let the user type more chars) + } + is Async.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + is Async.Success -> { + items(state.userInputResult.data) { homeserverData -> + val item = homeserverData.toAccountProvider() + AccountProviderView( + item = item, + onClick = { + state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) + } + ) + } + } + Async.Uninitialized -> Unit + } + item { + Spacer(Modifier.height(32.dp)) + } + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClicked = onLearnMoreClicked, + onDone = onDone, + ) + } + } +} + +@Composable +private fun HomeserverData.toAccountProvider(): AccountProvider { + val isMatrixOrg = homeserverUrl == "https://matrix.org" + return AccountProvider( + title = homeserverUrl.removePrefix("http://").removePrefix("https://"), + subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, + isPublic = isMatrixOrg, // There is no need to know for other servers right now + isMatrixOrg = isMatrixOrg, + isValid = isWellknownValid, + supportSlidingSync = supportSlidingSync, + ) +} + +@Preview +@Composable +fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: SearchAccountProviderState) { + SearchAccountProviderView( + state = state, + onBackPressed = {}, + onLearnMoreClicked = {}, + onDone = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt new file mode 100644 index 0000000000..5ceee99f91 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +sealed interface WaitListEvents { + object AttemptLogin : WaitListEvents + object ClearError : WaitListEvents + object Continue : WaitListEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt new file mode 100644 index 0000000000..24b5f271a0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class WaitListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: WaitListPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs(val loginFormState: LoginFormState) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.loginFormState) + + interface Callback : Plugin { + fun onCancelClicked() + } + + private fun onCancelClicked() { + plugins<Callback>().forEach { it.onCancelClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + WaitListView( + state = state, + onCancelClicked = ::onCancelClicked, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt new file mode 100644 index 0000000000..9c07204ab2 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenter.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +class WaitListPresenter @AssistedInject constructor( + @Assisted private val formState: LoginFormState, + private val buildMeta: BuildMeta, + private val authenticationService: MatrixAuthenticationService, + private val defaultLoginUserStory: DefaultLoginUserStory, +) : Presenter<WaitListState> { + + @AssistedFactory + interface Factory { + fun create(loginFormState: LoginFormState): WaitListPresenter + } + + @Composable + override fun present(): WaitListState { + val coroutineScope = rememberCoroutineScope() + val homeserverUrl = remember { + authenticationService.getHomeserverDetails().value?.url ?: "server" + } + + val loginAction: MutableState<Async<SessionId>> = remember { + mutableStateOf(Async.Uninitialized) + } + + val attemptNumber: MutableState<Int> = remember { mutableStateOf(0) } + + fun handleEvents(event: WaitListEvents) { + when (event) { + WaitListEvents.AttemptLogin -> { + // Do not attempt to login on first resume of the View. + attemptNumber.value++ + if (attemptNumber.value > 1) { + coroutineScope.loginAttempt(formState, loginAction) + } + } + WaitListEvents.ClearError -> loginAction.value = Async.Uninitialized + WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true) + } + } + + return WaitListState( + appName = buildMeta.applicationName, + serverName = homeserverUrl, + loginAction = loginAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch { + Timber.w("Attempt to login...") + loggedInState.value = Async.Loading() + authenticationService.login(formState.login.trim(), formState.password) + .onSuccess { sessionId -> + loggedInState.value = Async.Success(sessionId) + } + .onFailure { failure -> + loggedInState.value = Async.Failure(failure) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt new file mode 100644 index 0000000000..f50de7e194 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId + +// Do not use default value, so no member get forgotten in the presenters. +data class WaitListState( + val appName: String, + val serverName: String, + val loginAction: Async<SessionId>, + val eventSink: (WaitListEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt new file mode 100644 index 0000000000..5907ff1acf --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId + +open class WaitListStateProvider : PreviewParameterProvider<WaitListState> { + override val values: Sequence<WaitListState> + get() = sequenceOf( + aWaitListState(loginAction = Async.Uninitialized), + aWaitListState(loginAction = Async.Loading()), + aWaitListState(loginAction = Async.Failure(Throwable())), + aWaitListState(loginAction = Async.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))), + aWaitListState(loginAction = Async.Success(SessionId("@alice:element.io"))), + // Add other state here + ) +} + +fun aWaitListState( + appName: String = "Element X", + serverName: String = "server.org", + loginAction: Async<SessionId> = Async.Uninitialized, +) = WaitListState( + appName = appName, + serverName = serverName, + loginAction = loginAction, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt new file mode 100644 index 0000000000..73fbaf30f9 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAbsoluteAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.error.isWaitListError +import io.element.android.features.login.impl.error.loginError +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425 +// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode. +@Composable +fun WaitListView( + state: WaitListState, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin) + else -> Unit + } + } + + Box(modifier = modifier) { + WaitListBackground() + WaitListContent(state, onCancelClicked) + WaitListError(state) + } +} + +@Composable +private fun WaitListError(state: WaitListState) { + // Display a dialog for error other than the waitlist error + state.loginAction.errorOrNull()?.let { error -> + if (error.isWaitListError().not()) { + RetryDialog( + content = stringResource(id = loginError(error)), + onRetry = { + state.eventSink.invoke(WaitListEvents.AttemptLogin) + }, + onDismiss = { + state.eventSink.invoke(WaitListEvents.ClearError) + } + ) + } + } +} + +@Composable +private fun WaitListBackground( + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.3f) + .background(Color.White) + ) + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(id = R.drawable.light_dark), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.7f) + .background(Color(0xFF121418)) + ) + } +} + +@Composable +private fun WaitListContent( + state: WaitListState, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + if (state.loginAction !is Async.Success) { + TextButton( + onClick = onCancelClicked, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + disabledContainerColor = Color.White, + disabledContentColor = Color.Black, + ), + ) { + Text( + text = stringResource(CommonStrings.action_cancel), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAbsoluteAlignment( + horizontalBias = 0f, + verticalBias = -0.05f + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.loginAction.isLoading()) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } else { + Spacer(modifier = Modifier.height(24.dp)) + } + Spacer(modifier = Modifier.height(18.dp)) + val titleRes = when (state.loginAction) { + is Async.Success -> R.string.screen_waitlist_title_success + else -> R.string.screen_waitlist_title + } + Text( + text = withColoredPeriod(titleRes), + style = ElementTheme.typography.fontHeadingXlBold, + textAlign = TextAlign.Center, + color = Color.White, + ) + Spacer(modifier = Modifier.height(8.dp)) + val subtitle = when (state.loginAction) { + is Async.Success -> stringResource( + id = R.string.screen_waitlist_message_success, + state.appName, + ) + else -> stringResource( + id = R.string.screen_waitlist_message, + state.appName, + state.serverName, + ) + } + Text( + modifier = Modifier.widthIn(max = 360.dp), + text = subtitle, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + color = Color.White, + ) + } + } + if (state.loginAction is Async.Success) { + Button( + onClick = { state.eventSink.invoke(WaitListEvents.Continue) }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + disabledContainerColor = Color.White, + disabledContentColor = Color.Black, + ), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) + ) { + Text( + text = stringResource(id = CommonStrings.action_continue), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + } +} + +@Composable +private fun withColoredPeriod( + @StringRes textRes: Int, +) = buildAnnotatedString { + val text = stringResource(textRes) + append(text) + if (text.endsWith(".")) { + addStyle( + style = SpanStyle( + // Light.colorGreen700 + color = Color(0xff0bc491), + ), + start = text.length - 1, + end = text.length, + ) + } +} + +@Preview +@Composable +internal fun WaitListViewLightPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun WaitListViewDarkPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: WaitListState) { + WaitListView( + state = state, + onCancelClicked = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt new file mode 100644 index 0000000000..e8bcea990e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.util + +import io.element.android.features.login.impl.accountprovider.AccountProvider + +object LoginConstants { + const val MATRIX_ORG_URL = "matrix.org" + + const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev" + const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" +} + +val defaultAccountProvider = AccountProvider( + title = LoginConstants.DEFAULT_HOMESERVER_URL, + subtitle = null, + isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, + isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt new file mode 100644 index 0000000000..261b02c1b8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import io.element.android.libraries.core.data.tryOrNull + +fun openLearnMorePage(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL)) + tryOrNull { context.startActivity(intent) } +} diff --git a/features/login/impl/src/main/res/drawable/ic_homeserver.xml b/features/login/impl/src/main/res/drawable/ic_homeserver.xml new file mode 100644 index 0000000000..ee061f7007 --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_homeserver.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="42dp" + android:height="42dp" + android:viewportWidth="42" + android:viewportHeight="42"> + <group> + <clip-path + android:pathData="M0,0h42v42h-42z"/> + <path + android:pathData="M33.25,22.75H8.75C6.825,22.75 5.25,24.325 5.25,26.25V33.25C5.25,35.175 6.825,36.75 8.75,36.75H33.25C35.175,36.75 36.75,35.175 36.75,33.25V26.25C36.75,24.325 35.175,22.75 33.25,22.75ZM12.25,33.25C10.325,33.25 8.75,31.675 8.75,29.75C8.75,27.825 10.325,26.25 12.25,26.25C14.175,26.25 15.75,27.825 15.75,29.75C15.75,31.675 14.175,33.25 12.25,33.25ZM33.25,5.25H8.75C6.825,5.25 5.25,6.825 5.25,8.75V15.75C5.25,17.675 6.825,19.25 8.75,19.25H33.25C35.175,19.25 36.75,17.675 36.75,15.75V8.75C36.75,6.825 35.175,5.25 33.25,5.25ZM12.25,15.75C10.325,15.75 8.75,14.175 8.75,12.25C8.75,10.325 10.325,8.75 12.25,8.75C14.175,8.75 15.75,10.325 15.75,12.25C15.75,14.175 14.175,15.75 12.25,15.75Z" + android:fillColor="#737D8C"/> + </group> +</vector> diff --git a/features/login/impl/src/main/res/drawable/ic_matrix.xml b/features/login/impl/src/main/res/drawable/ic_matrix.xml new file mode 100644 index 0000000000..dbc788a031 --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_matrix.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + <path + android:pathData="M8,0L8,0A8,8 0,0 1,16 8L16,8A8,8 0,0 1,8 16L8,16A8,8 0,0 1,0 8L0,8A8,8 0,0 1,8 0z" + android:fillColor="#101317"/> + <path + android:pathData="M5.355,5.141V5.85H5.375C5.564,5.579 5.793,5.37 6.059,5.223C6.324,5.073 6.632,5 6.976,5C7.307,5 7.609,5.065 7.883,5.192C8.157,5.319 8.363,5.548 8.507,5.87C8.662,5.641 8.874,5.438 9.139,5.263C9.405,5.088 9.721,5 10.085,5C10.362,5 10.619,5.034 10.856,5.102C11.094,5.169 11.294,5.277 11.464,5.426C11.633,5.576 11.763,5.768 11.859,6.008C11.952,6.248 12,6.536 12,6.875V10.38H10.563V7.412C10.563,7.236 10.557,7.07 10.543,6.915C10.529,6.759 10.492,6.624 10.433,6.511C10.371,6.395 10.283,6.305 10.165,6.237C10.046,6.169 9.885,6.135 9.684,6.135C9.481,6.135 9.317,6.175 9.193,6.251C9.069,6.33 8.97,6.429 8.899,6.556C8.829,6.68 8.781,6.821 8.758,6.982C8.736,7.14 8.722,7.301 8.722,7.462V10.38H7.284V7.443C7.284,7.287 7.281,7.135 7.273,6.982C7.267,6.83 7.236,6.691 7.185,6.562C7.134,6.435 7.05,6.33 6.931,6.254C6.813,6.178 6.64,6.138 6.409,6.138C6.341,6.138 6.251,6.152 6.14,6.183C6.03,6.214 5.92,6.271 5.816,6.355C5.711,6.44 5.621,6.562 5.547,6.72C5.474,6.878 5.437,7.087 5.437,7.344V10.382H4V5.141H5.355Z" + android:fillColor="#EBEEF2"/> +</vector> diff --git a/features/login/impl/src/main/res/drawable/ic_public.xml b/features/login/impl/src/main/res/drawable/ic_public.xml new file mode 100644 index 0000000000..fc1eacbc9f --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_public.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + <path + android:pathData="M16,8C16,12.418 12.418,16 8,16C3.582,16 0,12.418 0,8C0,3.582 3.582,0 8,0C12.418,0 16,3.582 16,8Z" + android:fillColor="#818A95"/> + <path + android:pathData="M12.473,12.527L13.079,10.656C13.087,10.631 13.091,10.605 13.091,10.579V10.065C13.091,10 13.066,9.938 13.02,9.891L12.483,9.339C12.464,9.319 12.442,9.303 12.418,9.291L11.218,8.674C11.194,8.661 11.172,8.645 11.153,8.625L10.619,8.076C10.572,8.028 10.507,8 10.44,8H8.25C8.112,8 8,7.888 8,7.75V6.941C8,6.803 7.888,6.691 7.75,6.691H6.341C6.203,6.691 6.091,6.579 6.091,6.441V5.689C6.091,5.53 6.236,5.412 6.391,5.444L8.972,5.975C9.128,6.007 9.273,5.888 9.273,5.73V4.829C9.273,4.764 9.298,4.701 9.344,4.655L11.012,2.938C11.107,2.841 11.107,2.687 11.012,2.59L9.948,1.494C9.922,1.468 9.892,1.448 9.857,1.435L9.37,1.249C8.482,0.911 7.507,0.88 6.6,1.161L6.091,1.318C4.776,1.747 3.742,2.774 3.305,4.086L3.081,4.758C3.027,4.919 2.995,5.086 2.986,5.255L2.915,6.582C2.911,6.652 2.937,6.72 2.985,6.77L4.108,7.925C4.155,7.973 4.22,8 4.288,8H5.394C5.434,8 5.473,8.01 5.508,8.028L6.592,8.585C6.675,8.628 6.727,8.714 6.727,8.807V10.561C6.727,10.599 6.736,10.636 6.753,10.67L7.295,11.787C7.337,11.873 7.424,11.927 7.52,11.927H8.531C8.598,11.927 8.663,11.955 8.71,12.003L9.273,12.582L9.881,13.208C9.9,13.227 9.915,13.249 9.927,13.273L10.39,14.225C10.466,14.381 10.673,14.415 10.794,14.29L11.182,13.891L11.818,13.237L12.414,12.624C12.441,12.596 12.461,12.563 12.473,12.527Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/features/login/impl/src/main/res/drawable/light_dark.png b/features/login/impl/src/main/res/drawable/light_dark.png new file mode 100644 index 0000000000..2ff7516878 Binary files /dev/null and b/features/login/impl/src/main/res/drawable/light_dark.png differ diff --git a/features/login/impl/src/main/res/drawable/onboarding_icon_light.png b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png new file mode 100644 index 0000000000..ffd8631c47 Binary files /dev/null and b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png differ diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..55bc53e143 --- /dev/null +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Změna poskytovatele účtu"</string> + <string name="screen_account_provider_continue">"Pokračovat"</string> + <string name="screen_account_provider_form_hint">"Adresa domovského serveru"</string> + <string name="screen_account_provider_form_notice">"Zadejte hledaný výraz nebo adresu domény."</string> + <string name="screen_account_provider_form_subtitle">"Vyhledejte společnost, komunitu nebo soukromý server."</string> + <string name="screen_account_provider_form_title">"Najít poskytovatele účtu"</string> + <string name="screen_account_provider_signin_subtitle">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string> + <string name="screen_account_provider_signin_title">"Chystáte se přihlásit do %s"</string> + <string name="screen_account_provider_signup_subtitle">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string> + <string name="screen_account_provider_signup_title">"Chystáte se vytvořit účet na %s"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otevřená síť pro bezpečnou, decentralizovanou komunikaci."</string> + <string name="screen_change_account_provider_other">"Jiný"</string> + <string name="screen_change_account_provider_subtitle">"Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet."</string> + <string name="screen_change_account_provider_title">"Změnit poskytovatele účtu"</string> + <string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Tento server v současné době nepodporuje klouzavou synchronizaci."</string> + <string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string> + <string name="screen_change_server_form_notice">"Můžete se připojit pouze k serveru, který podporuje klouzavou synchronizaci. Správce vašeho domovského serveru jej bude muset nakonfigurovat. %1$s"</string> + <string name="screen_change_server_subtitle">"Jaká je adresa vašeho serveru?"</string> + <string name="screen_login_error_deactivated_account">"Tento účet byl deaktivován."</string> + <string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string> + <string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string> + <string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string> + <string name="screen_login_form_header">"Zadejte své údaje"</string> + <string name="screen_login_title">"Vítejte zpět!"</string> + <string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string> + <string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"Soukromý server pro zaměstnance Elementu."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string> + <string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string> + <string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string> + <string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string> + <string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu. + +Díky za trpělivost!"</string> + <string name="screen_waitlist_message_success">"Vítá vás %1$s"</string> + <string name="screen_waitlist_title">"Jste v pořadníku!"</string> + <string name="screen_waitlist_title_success">"Jdete do toho!"</string> + <string name="screen_change_server_submit">"Pokračovat"</string> + <string name="screen_change_server_title">"Vyberte svůj server"</string> + <string name="screen_login_password_hint">"Heslo"</string> + <string name="screen_login_submit">"Pokračovat"</string> + <string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string> + <string name="screen_login_username_hint">"Uživatelské jméno"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..efc7c0cf3c --- /dev/null +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Kontoanbieter wechseln"</string> + <string name="screen_account_provider_continue">"Weiter"</string> + <string name="screen_account_provider_form_hint">"Adresse des Homeservers"</string> + <string name="screen_account_provider_form_notice">"Geben Sie einen Suchbegriff oder eine Domainadresse ein."</string> + <string name="screen_account_provider_form_subtitle">"Suche nach einem Unternehmen, einer Community oder einem privaten Server."</string> + <string name="screen_account_provider_form_title">"Finde einen Accountanbieter"</string> + <string name="screen_account_provider_signin_subtitle">"Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string> + <string name="screen_account_provider_signin_title">"Du bist dabei dich bei %s anzumelden"</string> + <string name="screen_account_provider_signup_subtitle">"Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string> + <string name="screen_account_provider_signup_title">"Du bist dabei einen Account auf %s zu erstellen"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation."</string> + <string name="screen_change_account_provider_other">"Andere"</string> + <string name="screen_change_account_provider_subtitle">"Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."</string> + <string name="screen_change_account_provider_title">"Kontoanbieter ändern"</string> + <string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, dass du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator für weitere Hilfe."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit keine Sliding Sync."</string> + <string name="screen_change_server_form_header">"Homeserver-URL"</string> + <string name="screen_change_server_form_notice">"Du kannst dich nur mit einem existierenden Server verbinden, der Sliding Sync unterstützt. Dein Homeserver-Administrator muss es konfigurieren. %1$s"</string> + <string name="screen_change_server_subtitle">"Wie lautet die Adresse deines Servers?"</string> + <string name="screen_login_error_deactivated_account">"Dieses Konto wurde deaktiviert."</string> + <string name="screen_login_error_invalid_credentials">"Falscher Benutzername und/oder Passwort"</string> + <string name="screen_login_error_invalid_user_id">"Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"</string> + <string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."</string> + <string name="screen_login_form_header">"Gib deine Daten ein"</string> + <string name="screen_login_title">"Willkommen zurück!"</string> + <string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string> + <string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"Ein privater Server für Element-Mitarbeiter."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"</string> + <string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string> + <string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string> + <string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string> + <string name="screen_waitlist_message">"Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut. + +Vielen Dank für deine Geduld!"</string> + <string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string> + <string name="screen_waitlist_title">"Du hast es fast geschafft!"</string> + <string name="screen_waitlist_title_success">"Du bist dabei."</string> + <string name="screen_change_server_submit">"Weiter"</string> + <string name="screen_change_server_title">"Wählen deinen Server"</string> + <string name="screen_login_password_hint">"Passwort"</string> + <string name="screen_login_submit">"Weiter"</string> + <string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"</string> + <string name="screen_login_username_hint">"Benutzername"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..18d34d5e23 --- /dev/null +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_change_server_error_invalid_homeserver">"No hemos podido acceder a este servidor. Comprueba que has introducido correctamente la dirección del servidor. Si la dirección es correcta, ponte en contacto con el administrador del servidor para obtener más ayuda."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Este servidor no soporta sliding sync."</string> + <string name="screen_change_server_form_header">"Dirección del homeserver"</string> + <string name="screen_change_server_form_notice">"Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s"</string> + <string name="screen_change_server_subtitle">"¿Cuál es la dirección de tu servidor?"</string> + <string name="screen_login_error_deactivated_account">"Esta cuenta ha sido desactivada."</string> + <string name="screen_login_error_invalid_credentials">"Usuario y/o contraseña incorrectos"</string> + <string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string> + <string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string> + <string name="screen_login_form_header">"Introduce tus datos"</string> + <string name="screen_login_title">"¡Hola de nuevo!"</string> + <string name="screen_change_server_submit">"Continuar"</string> + <string name="screen_change_server_title">"Selecciona tu servidor"</string> + <string name="screen_login_password_hint">"Contraseña"</string> + <string name="screen_login_submit">"Continuar"</string> + <string name="screen_login_username_hint">"Usuario"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..c4cd027498 --- /dev/null +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Changer de fournisseur"</string> + <string name="screen_account_provider_continue">"Continuer"</string> + <string name="screen_account_provider_form_hint">"Adresse du serveur d\'accueil"</string> + <string name="screen_account_provider_form_notice">"Entrez un mot clé de recherche ou un nom de domaine."</string> + <string name="screen_account_provider_form_subtitle">"Rechercher une entreprise, une communauté ou un serveur privé."</string> + <string name="screen_account_provider_form_title">"Trouver un fournisseur de services"</string> + <string name="screen_account_provider_signin_subtitle">"C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."</string> + <string name="screen_account_provider_signin_title">"Vous êtes sur le point de vous connecter à %s"</string> + <string name="screen_account_provider_signup_subtitle">"C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."</string> + <string name="screen_account_provider_signup_title">"Vous êtes sur le point de créer un compte sur %s"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org est un réseau ouvert pour des communications sécurisées et décentralisées."</string> + <string name="screen_change_account_provider_other">"Autre"</string> + <string name="screen_change_account_provider_subtitle">"Utilisez un autre fournisseur de compte, tel que votre propre serveur ou un compte professionnel."</string> + <string name="screen_change_account_provider_title">"Changer de fournisseur"</string> + <string name="screen_change_server_error_invalid_homeserver">"Nous n\'avons pas pu atteindre ce serveur domestique. Vérifiez que vous avez correctement saisi l\'URL du serveur d\'accueil. Si l\'URL est correcte, contactez l\'administrateur de votre serveur domestique pour obtenir de l\'aide."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Ce serveur ne prend actuellement pas en charge la synchronisation glissante."</string> + <string name="screen_change_server_form_header">"URL du serveur d\'accueil"</string> + <string name="screen_change_server_form_notice">"Vous ne pouvez vous connecter qu\'à un serveur existant qui prend en charge la synchronisation glissante. L\'administrateur de votre serveur domestique devra la configurer. %1$s"</string> + <string name="screen_change_server_subtitle">"Quelle est l\'adresse de votre serveur ?"</string> + <string name="screen_login_error_deactivated_account">"Ce compte a été désactivé."</string> + <string name="screen_login_error_invalid_credentials">"Nom d\'utilisateur et/ou mot de passe incorrect"</string> + <string name="screen_login_error_invalid_user_id">"Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string> + <string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string> + <string name="screen_login_form_header">"Saisir vos informations personnelles"</string> + <string name="screen_login_title">"Heureux de vous revoir!"</string> + <string name="screen_login_title_with_homeserver">"Se connecter à %1$s"</string> + <string name="screen_server_confirmation_change_server">"Changer de fournisseur de compte"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"Un serveur privé pour les employés d’Element."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string> + <string name="screen_server_confirmation_message_register">"C\'est là que vos conversations seront conservées — de la même manière que votre service d’e-mail habituel conserverait vos e-mails."</string> + <string name="screen_server_confirmation_title_login">"Vous allez vous connecter à %1$s"</string> + <string name="screen_server_confirmation_title_register">"Vous allez créer un compte sur %1$s"</string> + <string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s en ce moment. Rouvrez l’app dans quelques jours et réessayez. + +Merci de votre patience !"</string> + <string name="screen_waitlist_message_success">"Bienvenue sur %1$s !"</string> + <string name="screen_waitlist_title">"Vous y êtes presque."</string> + <string name="screen_waitlist_title_success">"Vous y êtes."</string> + <string name="screen_change_server_submit">"Continuer"</string> + <string name="screen_change_server_title">"Sélectionnez votre serveur"</string> + <string name="screen_login_password_hint">"Mot de passe"</string> + <string name="screen_login_submit">"Continuer"</string> + <string name="screen_login_subtitle">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string> + <string name="screen_login_username_hint">"Nom d\'utilisateur"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..bb054b0577 --- /dev/null +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_change_server_error_invalid_homeserver">"Non siamo riusciti a raggiungere questo homserver. Verifica di aver inserito correttamente l\'URL del server domestico. Se l\'URL è corretto, contatta l\'amministratore del tuo server domestico per ulteriore assistenza."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Questo server attualmente non supporta la sincronizzazione scorrevole."</string> + <string name="screen_change_server_form_header">"URL dell\'homeserver"</string> + <string name="screen_change_server_form_notice">"Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s"</string> + <string name="screen_change_server_subtitle">"Qual è l\'indirizzo del tuo server?"</string> + <string name="screen_login_error_deactivated_account">"Questo profilo è stato disattivato."</string> + <string name="screen_login_error_invalid_credentials">"Nome utente e/o password errati"</string> + <string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string> + <string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string> + <string name="screen_login_form_header">"Inserisci i tuoi dati"</string> + <string name="screen_login_title">"Bentornato!"</string> + <string name="screen_change_server_submit">"Continua"</string> + <string name="screen_change_server_title">"Seleziona il tuo server"</string> + <string name="screen_login_password_hint">"Password"</string> + <string name="screen_login_submit">"Continua"</string> + <string name="screen_login_username_hint">"Nome utente"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..c2dcadb6ad --- /dev/null +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Schimbați furnizorul contului"</string> + <string name="screen_account_provider_continue">"Continuați"</string> + <string name="screen_account_provider_form_hint">"Adresa Homeserver-ului"</string> + <string name="screen_account_provider_form_notice">"Introduceţi un termen de căutare sau o adresă de domeniu."</string> + <string name="screen_account_provider_form_subtitle">"Căutați o companie, o comunitate sau un server privat."</string> + <string name="screen_account_provider_form_title">"Găsiți un furnizor de cont"</string> + <string name="screen_account_provider_signin_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string> + <string name="screen_account_provider_signin_title">"Sunteți pe cale să vă conectați la %s"</string> + <string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string> + <string name="screen_account_provider_signup_title">"Sunteți pe cale să creați un cont pe %s"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org este o rețea deschisă pentru o comunicare sigură și descentralizată."</string> + <string name="screen_change_account_provider_other">"Altul"</string> + <string name="screen_change_account_provider_subtitle">"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."</string> + <string name="screen_change_account_provider_title">"Schimbați furnizorul contului"</string> + <string name="screen_change_server_error_invalid_homeserver">"Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string> + <string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string> + <string name="screen_change_server_form_notice">"Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s"</string> + <string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string> + <string name="screen_login_error_deactivated_account">"Acest cont a fost dezactivat."</string> + <string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string> + <string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string> + <string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string> + <string name="screen_login_form_header">"Introduceți detaliile"</string> + <string name="screen_login_title">"Bine ați revenit!"</string> + <string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string> + <string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"Un server privat pentru angajații Element."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string> + <string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string> + <string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string> + <string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string> + <string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou. + +Vă mulțumim pentru răbdare!"</string> + <string name="screen_waitlist_message_success">"Bun venit la %1$s"</string> + <string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string> + <string name="screen_waitlist_title_success">"Sunteți conectat!"</string> + <string name="screen_change_server_submit">"Continuați"</string> + <string name="screen_change_server_title">"Selectați serverul"</string> + <string name="screen_login_password_hint">"Parola"</string> + <string name="screen_login_submit">"Continuați"</string> + <string name="screen_login_subtitle">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string> + <string name="screen_login_username_hint">"Utilizator"</string> +</resources> diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..2cdaf596e1 --- /dev/null +++ b/features/login/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Zmeniť poskytovateľa účtu"</string> + <string name="screen_account_provider_continue">"Pokračovať"</string> + <string name="screen_account_provider_form_hint">"Adresa domovského servera"</string> + <string name="screen_account_provider_form_notice">"Zadajte hľadaný výraz alebo adresu domény."</string> + <string name="screen_account_provider_form_subtitle">"Vyhľadať spoločnosť, komunitu alebo súkromný server."</string> + <string name="screen_account_provider_form_title">"Nájsť poskytovateľa účtu"</string> + <string name="screen_account_provider_signin_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string> + <string name="screen_account_provider_signin_title">"Chystáte sa prihlásiť do %s"</string> + <string name="screen_account_provider_signup_subtitle">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string> + <string name="screen_account_provider_signup_title">"Chystáte sa vytvoriť účet na %s"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string> + <string name="screen_change_account_provider_other">"Iný"</string> + <string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string> + <string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string> + <string name="screen_change_server_error_invalid_homeserver">"Nemohli sme sa spojiť s týmto domovským serverom. Skontrolujte prosím, či ste zadali URL adresu domovského servera správne. Ak je adresa URL správna, kontaktujte svoj domovský server pre ďalšiu pomoc."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"Tento server momentálne nepodporuje kĺzavú synchronizáciu."</string> + <string name="screen_change_server_form_header">"Adresa URL domovského servera"</string> + <string name="screen_change_server_form_notice">"Pripojiť sa môžete len k existujúcemu serveru, ktorý podporuje kĺzavú synchronizáciu. Váš správca domovského servera ju bude musieť nakonfigurovať. %1$s"</string> + <string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string> + <string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string> + <string name="screen_login_error_invalid_credentials">"Nesprávne používateľské meno a/alebo heslo"</string> + <string name="screen_login_error_invalid_user_id">"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"</string> + <string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server."</string> + <string name="screen_login_form_header">"Zadajte svoje údaje"</string> + <string name="screen_login_title">"Vitajte späť!"</string> + <string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string> + <string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"Súkromný server pre zamestnancov spoločnosti Element."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string> + <string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string> + <string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string> + <string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string> + <string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova. + +Ďakujeme za trpezlivosť!"</string> + <string name="screen_waitlist_message_success">"Vitajte v %1$s"</string> + <string name="screen_waitlist_title">"Ste na čakanej listine!"</string> + <string name="screen_waitlist_title_success">"Ste dnu!"</string> + <string name="screen_change_server_submit">"Pokračovať"</string> + <string name="screen_change_server_title">"Vyberte svoj server"</string> + <string name="screen_login_password_hint">"Heslo"</string> + <string name="screen_login_submit">"Pokračovať"</string> + <string name="screen_login_subtitle">"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."</string> + <string name="screen_login_username_hint">"Používateľské meno"</string> +</resources> diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..893f1674a3 --- /dev/null +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_account_provider_change">"Change account provider"</string> + <string name="screen_account_provider_continue">"Continue"</string> + <string name="screen_account_provider_form_hint">"Homeserver address"</string> + <string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string> + <string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string> + <string name="screen_account_provider_form_title">"Find an account provider"</string> + <string name="screen_account_provider_signin_subtitle">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string> + <string name="screen_account_provider_signin_title">"You’re about to sign in to %s"</string> + <string name="screen_account_provider_signup_subtitle">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string> + <string name="screen_account_provider_signup_title">"You’re about to create an account on %s"</string> + <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is an open network for secure, decentralized communication."</string> + <string name="screen_change_account_provider_other">"Other"</string> + <string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string> + <string name="screen_change_account_provider_title">"Change account provider"</string> + <string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string> + <string name="screen_change_server_error_no_sliding_sync_message">"This server currently doesn’t support sliding sync."</string> + <string name="screen_change_server_form_header">"Homeserver URL"</string> + <string name="screen_change_server_form_notice">"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"</string> + <string name="screen_change_server_subtitle">"What is the address of your server?"</string> + <string name="screen_login_error_deactivated_account">"This account has been deactivated."</string> + <string name="screen_login_error_invalid_credentials">"Incorrect username and/or password"</string> + <string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"</string> + <string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string> + <string name="screen_login_form_header">"Enter your details"</string> + <string name="screen_login_title">"Welcome back!"</string> + <string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string> + <string name="screen_server_confirmation_change_server">"Change account provider"</string> + <string name="screen_server_confirmation_message_login_element_dot_io">"A private server for Element employees."</string> + <string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string> + <string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string> + <string name="screen_server_confirmation_title_login">"You’re about to sign in to %1$s"</string> + <string name="screen_server_confirmation_title_register">"You’re about to create an account on %1$s"</string> + <string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again. + +Thanks for your patience!"</string> + <string name="screen_waitlist_message_success">"Welcome to %1$s!"</string> + <string name="screen_waitlist_title">"You’re almost there."</string> + <string name="screen_waitlist_title_success">"You\'re in."</string> + <string name="screen_change_server_submit">"Continue"</string> + <string name="screen_change_server_title">"Select your server"</string> + <string name="screen_login_password_hint">"Password"</string> + <string name="screen_login_submit">"Continue"</string> + <string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string> + <string name="screen_login_username_hint">"Username"</string> +</resources> diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt new file mode 100644 index 0000000000..9aefafb382 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.changeserver + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeServerPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - change server ok`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = ChangeServerPresenter( + authenticationService, + AccountProviderDataSource() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) + authenticationService.givenHomeserver(A_HOMESERVER) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.changeServerAction).isEqualTo(Async.Success(Unit)) + } + } + + @Test + fun `present - change server error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = ChangeServerPresenter( + authenticationService, + AccountProviderDataSource() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) + val failureState = awaitItem() + assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java) + // Clear error + failureState.eventSink.invoke(ChangeServerEvents.ClearError) + val finalState = awaitItem() + assertThat(finalState.changeServerAction).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt new file mode 100644 index 0000000000..c1d7e5bb6c --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.error + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.R +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import io.element.android.libraries.ui.strings.CommonStrings +import org.junit.Test + +class ErrorFormatterTests { + + // region loginError + @Test + fun `loginError - invalid unknown error returns unknown error message`() { + val error = Throwable("Some unknown error") + assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown) + } + + @Test + fun `loginError - invalid auth error returns unknown error message`() { + val error = AuthenticationException.SlidingSyncNotAvailable("Some message. Also contains M_FORBIDDEN, but won't be parsed") + assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown) + } + + @Test + fun `loginError - unknown error returns unknown error message`() { + val error = AuthenticationException.Generic("M_UNKNOWN") + assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown) + } + + @Test + fun `loginError - forbidden error returns incorrect credentials message`() { + val error = AuthenticationException.Generic("M_FORBIDDEN") + assertThat(loginError(error)).isEqualTo(R.string.screen_login_error_invalid_credentials) + } + + @Test + fun `loginError - user_deactivated error returns deactivated account message`() { + val error = AuthenticationException.Generic("M_USER_DEACTIVATED") + assertThat(loginError(error)).isEqualTo(R.string.screen_login_error_deactivated_account) + } + + // endregion loginError +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParserTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParserTest.kt new file mode 100644 index 0000000000..a0275f8f47 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/OidcUrlParserTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.oidc + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.libraries.matrix.api.auth.OidcConfig +import org.junit.Assert +import org.junit.Test + +class OidcUrlParserTest { + @Test + fun `test empty url`() { + val sut = OidcUrlParser() + assertThat(sut.parse("")).isNull() + } + + @Test + fun `test regular url`() { + val sut = OidcUrlParser() + assertThat(sut.parse("https://matrix.org")).isNull() + } + + @Test + fun `test cancel url`() { + val sut = OidcUrlParser() + val aCancelUrl = OidcConfig.redirectUri + "?error=access_denied&state=IFF1UETGye2ZA8pO" + assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack) + } + + @Test + fun `test success url`() { + val sut = OidcUrlParser() + val aSuccessUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" + assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl)) + } + + @Test + fun `test unknown url`() { + val sut = OidcUrlParser() + val anUnknownUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" + Assert.assertThrows(IllegalStateException::class.java) { + assertThat(sut.parse(anUnknownUrl)) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt new file mode 100644 index 0000000000..5756cd13d2 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.impl.oidc.webview + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.api.oidc.OidcAction +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class OidcPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = OidcPresenter( + A_OIDC_DATA, + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA) + assertThat(initialState.requestState).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - go back`() = runTest { + val presenter = OidcPresenter( + A_OIDC_DATA, + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(OidcEvents.Cancel) + val loadingState = awaitItem() + assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>()) + val finalState = awaitItem() + assertThat(finalState.requestState).isEqualTo(Async.Success(Unit)) + } + } + + @Test + fun `present - go back with failure`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = OidcPresenter( + A_OIDC_DATA, + authenticationService, + ) + authenticationService.givenOidcCancelError(A_THROWABLE) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(OidcEvents.Cancel) + val loadingState = awaitItem() + assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>()) + val finalState = awaitItem() + assertThat(finalState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE)) + // Note: in real life I do not think this can happen, and the app should not block the user. + } + } + + @Test + fun `present - user cancels from webview`() = runTest { + val presenter = OidcPresenter( + A_OIDC_DATA, + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack)) + val loadingState = awaitItem() + assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>()) + val finalState = awaitItem() + assertThat(finalState.requestState).isEqualTo(Async.Success(Unit)) + } + } + + @Test + fun `present - login success`() = runTest { + val presenter = OidcPresenter( + A_OIDC_DATA, + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL"))) + val loadingState = awaitItem() + assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>()) + // In this case, no success, the session is created and the node get destroyed. + } + } + + @Test + fun `present - login error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = OidcPresenter( + A_OIDC_DATA, + authenticationService, + ) + authenticationService.givenLoginError(A_THROWABLE) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL"))) + val loadingState = awaitItem() + assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>()) + val errorState = awaitItem() + assertThat(errorState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE)) + errorState.eventSink.invoke(OidcEvents.ClearError) + val finalState = awaitItem() + assertThat(finalState.requestState).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt new file mode 100644 index 0000000000..58c2bf82a3 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/resolver/network/FakeWellknownRequest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.resolver.network + +class FakeWellknownRequest : WellknownRequest { + private var resultMap: Map<String, WellKnown> = emptyMap() + fun givenResultMap(map: Map<String, WellKnown>) { + resultMap = map + } + + override suspend fun execute(baseUrl: String): WellKnown { + return resultMap[baseUrl] ?: error("No result provided for $baseUrl") + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..086428257a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.changeaccountprovider + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeAccountProviderPresenterTest { + @Test + fun `present - initial state`() = runTest { + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = ChangeAccountProviderPresenter( + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).isEqualTo( + listOf( + AccountProvider( + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + isValid = true, + supportSlidingSync = true, + ) + ) + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..131d0d9298 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.confirmaccountprovider + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ConfirmAccountProviderPresenterTest { + @Test + fun `present - initial test`() = runTest { + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isAccountCreation).isFalse() + assertThat(initialState.submitEnabled).isTrue() + assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider) + assertThat(initialState.loginFlow).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - continue password login`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + authServer.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isEqualTo(LoginFlow.PasswordLogin) + } + } + + @Test + fun `present - continue oidc`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + authServer.givenHomeserver(A_HOMESERVER_OIDC) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginFlow).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginFlow).isInstanceOf(Async.Success::class.java) + assertThat(successState.loginFlow.dataOrNull()).isInstanceOf(LoginFlow.OidcFlow::class.java) + } + } + + @Test + fun `present - submit fails`() = runTest { + val authServer = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authServer, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + authServer.givenChangeServerError(Throwable()) + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val failureState = awaitItem() + assertThat(failureState.submitEnabled).isFalse() + assertThat(failureState.loginFlow).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = ConfirmAccountProviderPresenter( + ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + AccountProviderDataSource(), + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // Submit will return an error + authenticationService.givenChangeServerError(A_THROWABLE) + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + + skipItems(1) // Loading + + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginFlow).isInstanceOf(Async.Failure::class.java) + + // Assert the error is then cleared + submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginFlow).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt new file mode 100644 index 0000000000..afd4b542e4 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.loginpassword + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_PASSWORD +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoginPasswordPresenterTest { + @Test + fun `present - initial state`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val loginUserStory = DefaultLoginUserStory() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + loginUserStory, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountProvider).isEqualTo(defaultAccountProvider) + assertThat(initialState.formState).isEqualTo(LoginFormState.Default) + assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - enter login and password`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val loginUserStory = DefaultLoginUserStory() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + loginUserStory, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + val loginState = awaitItem() + assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) + assertThat(loginState.submitEnabled).isFalse() + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + val loginAndPasswordState = awaitItem() + assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) + assertThat(loginAndPasswordState.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + loginUserStory, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(loginUserStory.loginFlowIsDone.value).isFalse() + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID)) + assertThat(loginUserStory.loginFlowIsDone.value).isTrue() + } + } + + @Test + fun `present - submit with error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val loginUserStory = DefaultLoginUserStory() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + loginUserStory, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure<SessionId>(A_THROWABLE)) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeAuthenticationService() + val accountProviderDataSource = AccountProviderDataSource() + val loginUserStory = DefaultLoginUserStory() + val presenter = LoginPasswordPresenter( + authenticationService, + accountProviderDataSource, + loginUserStory, + ) + authenticationService.givenHomeserver(A_HOMESERVER) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME)) + initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_THROWABLE) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val loggedInState = awaitItem() + // Check an error was returned + assertThat(loggedInState.loginAction).isEqualTo(Async.Failure<SessionId>(A_THROWABLE)) + // Assert the error is then cleared + loggedInState.eventSink(LoginPasswordEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt new file mode 100644 index 0000000000..9163f247f5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.searchaccountprovider + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.features.login.impl.resolver.network.FakeWellknownRequest +import io.element.android.features.login.impl.resolver.network.WellKnown +import io.element.android.features.login.impl.resolver.network.WellKnownBaseConfig +import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SearchAccountProviderPresenterTest { + @Test + fun `present - initial state`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userInput).isEmpty() + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter text no result`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - enter valid url no wellknown`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("https://test.org")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("https://test.org") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = false, supportSlidingSync = false) + ) + ) + ) + } + } + + @Test + fun `present - enter text one result no sliding sync`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + fakeWellknownRequest.givenResultMap( + mapOf( + "https://test.org" to aWellKnown().copy(slidingSyncProxy = null), + ) + ) + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org", isWellknownValid = true, supportSlidingSync = false) + ) + ) + ) + } + } + + @Test + fun `present - enter text one result with sliding sync`() = runTest { + val fakeWellknownRequest = FakeWellknownRequest() + fakeWellknownRequest.givenResultMap( + mapOf( + "https://test.io" to aWellKnown(), + ) + ) + val changeServerPresenter = ChangeServerPresenter( + FakeAuthenticationService(), + AccountProviderDataSource() + ) + val presenter = SearchAccountProviderPresenter( + HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRequest), + changeServerPresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(Async.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + Async.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.io") + ) + ) + ) + } + } + + private fun aWellKnown(): WellKnown { + return WellKnown( + homeServer = WellKnownBaseConfig( + baseURL = A_HOMESERVER_URL + ), + identityServer = WellKnownBaseConfig( + baseURL = A_HOMESERVER_URL + ), + slidingSyncProxy = WellKnownSlidingSyncConfig( + url = A_HOMESERVER_URL + ) + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt new file mode 100644 index 0000000000..389ac52176 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.login.impl.screens.waitlistscreen + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.screens.loginpassword.LoginFormState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_HOMESERVER +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class WaitListPresenterTest { + @Test + fun `present - initial state`() = runTest { + val authenticationService = FakeAuthenticationService().apply { + givenHomeserver(A_HOMESERVER) + } + val loginUserStory = DefaultLoginUserStory() + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(applicationName = "Application Name"), + authenticationService, + loginUserStory, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo("Application Name") + assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL) + assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - attempt login with error`() = runTest { + val authenticationService = FakeAuthenticationService().apply { + givenLoginError(A_THROWABLE) + } + val loginUserStory = DefaultLoginUserStory() + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(), + authenticationService, + loginUserStory, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // First usage of AttemptLogin, nothing should happen + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + expectNoEvents() + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.loginAction).isEqualTo(Async.Failure<SessionId>(A_THROWABLE)) + // Assert the error can be cleared + errorState.eventSink(WaitListEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - attempt login with success`() = runTest { + val authenticationService = FakeAuthenticationService() + val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } + val presenter = WaitListPresenter( + LoginFormState.Default, + aBuildMeta(), + authenticationService, + loginUserStory, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(loginUserStory.loginFlowIsDone.value).isFalse() + val initialState = awaitItem() + // First usage of AttemptLogin, nothing should happen + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + expectNoEvents() + initialState.eventSink.invoke(WaitListEvents.AttemptLogin) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.loginAction).isEqualTo(Async.Success(A_USER_ID)) + assertThat(loginUserStory.loginFlowIsDone.value).isFalse() + successState.eventSink.invoke(WaitListEvents.Continue) + assertThat(loginUserStory.loginFlowIsDone.value).isTrue() + } + } +} diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts new file mode 100644 index 0000000000..d2374142a4 --- /dev/null +++ b/features/logout/api/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.logout.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt new file mode 100644 index 0000000000..2dad1623ab --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.logout.api + +sealed interface LogoutPreferenceEvents { + object Logout : LogoutPreferenceEvents +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt new file mode 100644 index 0000000000..b42744f912 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.logout.api + +import io.element.android.libraries.architecture.Presenter + +interface LogoutPreferencePresenter : Presenter<LogoutPreferenceState> diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt new file mode 100644 index 0000000000..60844f4477 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceScreen.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.logout.api + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Logout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun LogoutPreferenceView( + state: LogoutPreferenceState, + onSuccessLogout: () -> Unit = {} +) { + val eventSink = state.eventSink + if (state.logoutAction is Async.Success) { + LaunchedEffect(state.logoutAction) { + onSuccessLogout() + } + return + } + val openDialog = remember { mutableStateOf(false) } + + LogoutPreferenceContent( + onClick = { + openDialog.value = true + } + ) + + // Log out confirmation dialog + if (openDialog.value) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_signout_confirmation_dialog_title), + content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), + submitText = stringResource(id = R.string.screen_signout_confirmation_dialog_submit), + onCancelClicked = { + openDialog.value = false + }, + onSubmitClicked = { + openDialog.value = false + eventSink(LogoutPreferenceEvents.Logout) + }, + onDismiss = { + openDialog.value = false + } + ) + } + + if (state.logoutAction is Async.Loading) { + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + } +} + +@Composable +fun LogoutPreferenceContent( + onClick: () -> Unit = {}, +) { + PreferenceText( + title = stringResource(id = R.string.screen_signout_preference_item), + icon = Icons.Filled.Logout, + onClick = onClick + ) +} + +@Preview +@Composable +internal fun LogoutPreferenceViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun LogoutPreferenceViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + LogoutPreferenceView(aLogoutPreferenceState()) +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt new file mode 100644 index 0000000000..e5fd05ba8e --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.logout.api + +import io.element.android.libraries.architecture.Async + +data class LogoutPreferenceState( + val logoutAction: Async<Unit>, + val eventSink: (LogoutPreferenceEvents) -> Unit, +) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt new file mode 100644 index 0000000000..3e729298b0 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.logout.api + +import io.element.android.libraries.architecture.Async + +fun aLogoutPreferenceState() = LogoutPreferenceState( + logoutAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/logout/api/src/main/res/values-cs/translations.xml b/features/logout/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..20be439d90 --- /dev/null +++ b/features/logout/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string> + <string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string> + <string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string> + <string name="screen_signout_preference_item">"Odhlásit se"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..0cd8ac389a --- /dev/null +++ b/features/logout/api/src/main/res/values-de/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Möchtest du Dich wirklich abmelden?"</string> + <string name="screen_signout_confirmation_dialog_title">"Abmelden"</string> + <string name="screen_signout_in_progress_dialog_content">"Abmeldung läuft…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Abmelden"</string> + <string name="screen_signout_preference_item">"Abmelden"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..5ac0656935 --- /dev/null +++ b/features/logout/api/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"¿Estás seguro de que quieres cerrar sesión?"</string> + <string name="screen_signout_confirmation_dialog_title">"Cerrar sesión"</string> + <string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Cerrar sesión"</string> + <string name="screen_signout_preference_item">"Cerrar sesión"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-fr/translations.xml b/features/logout/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..b6d5137072 --- /dev/null +++ b/features/logout/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Êtes-vous sûr de vouloir vous déconnecter?"</string> + <string name="screen_signout_confirmation_dialog_title">"Se déconnecter"</string> + <string name="screen_signout_in_progress_dialog_content">"Déconnexion en cours…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Se déconnecter"</string> + <string name="screen_signout_preference_item">"Se déconnecter"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..4e8217a7f2 --- /dev/null +++ b/features/logout/api/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Sei sicuro di voler uscire?"</string> + <string name="screen_signout_confirmation_dialog_title">"Esci"</string> + <string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Esci"</string> + <string name="screen_signout_preference_item">"Esci"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..4b2c7fbe7b --- /dev/null +++ b/features/logout/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Sunteți sigur că vreți să vă deconectați?"</string> + <string name="screen_signout_confirmation_dialog_title">"Deconectați-vă"</string> + <string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Deconectați-vă"</string> + <string name="screen_signout_preference_item">"Deconectați-vă"</string> +</resources> diff --git a/features/logout/api/src/main/res/values-sk/translations.xml b/features/logout/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..212f11ccbc --- /dev/null +++ b/features/logout/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string> + <string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string> + <string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string> + <string name="screen_signout_preference_item">"Odhlásiť sa"</string> +</resources> diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..9ea4bb77fd --- /dev/null +++ b/features/logout/api/src/main/res/values/localazy.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string> + <string name="screen_signout_confirmation_dialog_title">"Sign out"</string> + <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string> + <string name="screen_signout_confirmation_dialog_submit">"Sign out"</string> + <string name="screen_signout_preference_item">"Sign out"</string> +</resources> diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts new file mode 100644 index 0000000000..88d8282875 --- /dev/null +++ b/features/logout/impl/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.logout.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(libs.accompanist.placeholder) + api(projects.features.logout.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/logout/impl/src/main/AndroidManifest.xml b/features/logout/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e9c0841b6b --- /dev/null +++ b/features/logout/impl/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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"> + +</manifest> diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt new file mode 100644 index 0000000000..e957755b98 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.logout.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.logout.api.LogoutPreferenceEvents +import io.element.android.features.logout.api.LogoutPreferencePresenter +import io.element.android.features.logout.api.LogoutPreferenceState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : + LogoutPreferencePresenter { + + @Composable + override fun present(): LogoutPreferenceState { + val localCoroutineScope = rememberCoroutineScope() + val logoutAction: MutableState<Async<Unit>> = remember { + mutableStateOf(Async.Uninitialized) + } + + fun handleEvents(event: LogoutPreferenceEvents) { + when (event) { + LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction) + } + } + + return LogoutPreferenceState( + logoutAction = logoutAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch { + suspend { + matrixClient.logout() + }.runCatchingUpdatingState(logoutAction) + } +} diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt new file mode 100644 index 0000000000..bed33006d6 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.logout.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.api.LogoutPreferenceEvents +import io.element.android.features.logout.api.LogoutPreferenceState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LogoutPreferencePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = DefaultLogoutPreferencePresenter( + FakeMatrixClient(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - logout`() = runTest { + val presenter = DefaultLogoutPreferencePresenter( + FakeMatrixClient(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + @Test + fun `present - logout with error`() = runTest { + val matrixClient = FakeMatrixClient() + val presenter = DefaultLogoutPreferencePresenter( + matrixClient, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + matrixClient.givenLogoutError(A_THROWABLE) + initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isEqualTo(Async.Failure<LogoutPreferenceState>(A_THROWABLE)) + } + } +} + diff --git a/features/messages/api/.gitignore b/features/messages/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/messages/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts new file mode 100644 index 0000000000..756014e97d --- /dev/null +++ b/features/messages/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.messages.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + api(projects.libraries.textcomposer) +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt new file mode 100644 index 0000000000..5a0596c7bb --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.api + +import io.element.android.libraries.textcomposer.MessageComposerMode + +/** + * Hoist-able state of the message composer. + * + * Typical use case is inside other presenters, to know if + * the composer is in a thread, if it's editing a message, etc. + */ +interface MessageComposerContext { + val composerMode: MessageComposerMode +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt new file mode 100644 index 0000000000..482dfad8ea --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +interface MessagesEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onRoomDetailsClicked() + fun onUserDataClicked(userId: UserId) + fun onForwardedToSingleRoom(roomId: RoomId) + } +} diff --git a/features/messages/impl/.gitignore b/features/messages/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/messages/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts new file mode 100644 index 0000000000..7488820f7d --- /dev/null +++ b/features/messages/impl/build.gradle.kts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.messages.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.messages.api) + implementation(projects.features.location.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.textcomposer) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.features.networkmonitor.api) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(libs.datetime) + implementation(libs.accompanist.flowlayout) + implementation(libs.androidx.recyclerview) + implementation(libs.jsoup) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.accompanist.systemui) + implementation(libs.vanniktech.blurhash) + implementation(libs.telephoto.zoomableimage) + implementation(libs.vanniktech.emoji) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.analytics.test) + testImplementation(projects.tests.testutils) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(libs.test.mockk) + + androidTestImplementation(libs.test.junitext) + ksp(libs.showkase.processor) +} diff --git a/features/messages/impl/consumer-rules.pro b/features/messages/impl/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt new file mode 100644 index 0000000000..abf451b4b6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: MessagesEntryPoint.Callback + ): Node { + return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback)) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt new file mode 100644 index 0000000000..d475b5bc8c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface MessagesEvents { + data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents + data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents + data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents + object Dismiss : MessagesEvents +} + +enum class InviteDialogAction { + Cancel, + Invite, +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt new file mode 100644 index 0000000000..da10171d0a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode +import io.element.android.features.messages.impl.forward.ForwardMessagesNode +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.viewer.MediaViewerNode +import io.element.android.features.messages.impl.report.ReportMessageNode +import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +class MessagesFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val sendLocationEntryPoint: SendLocationEntryPoint, + private val showLocationEntryPoint: ShowLocationEntryPoint, +) : BackstackNode<MessagesFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Messages, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Messages : NavTarget + + @Parcelize + data class MediaViewer( + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : NavTarget + + @Parcelize + data class AttachmentPreview(val attachment: Attachment) : NavTarget + + @Parcelize + data class LocationViewer(val location: Location, val description: String?) : NavTarget + + @Parcelize + data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget + + @Parcelize + data class ForwardEvent(val eventId: EventId) : NavTarget + + @Parcelize + data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget + + @Parcelize + object SendLocation : NavTarget + } + + private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Messages -> { + val callback = object : MessagesNode.Callback { + override fun onRoomDetailsClicked() { + callback?.onRoomDetailsClicked() + } + + override fun onEventClicked(event: TimelineItem.Event) { + processEventClicked(event) + } + + override fun onPreviewAttachments(attachments: ImmutableList<Attachment>) { + backstack.push(NavTarget.AttachmentPreview(attachments.first())) + } + + override fun onUserDataClicked(userId: UserId) { + callback?.onUserDataClicked(userId) + } + + override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) + } + + override fun onForwardEventClicked(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId)) + } + + override fun onReportMessage(eventId: EventId, senderId: UserId) { + backstack.push(NavTarget.ReportMessage(eventId, senderId)) + } + + override fun onSendLocationClicked() { + backstack.push(NavTarget.SendLocation) + } + } + createNode<MessagesNode>(buildContext, listOf(callback)) + } + is NavTarget.MediaViewer -> { + val inputs = MediaViewerNode.Inputs( + mediaInfo = navTarget.mediaInfo, + mediaSource = navTarget.mediaSource, + thumbnailSource = navTarget.thumbnailSource, + ) + createNode<MediaViewerNode>(buildContext, listOf(inputs)) + } + is NavTarget.AttachmentPreview -> { + val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment) + createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs)) + } + is NavTarget.LocationViewer -> { + val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description) + showLocationEntryPoint.createNode(this, buildContext, inputs) + } + is NavTarget.EventDebugInfo -> { + val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) + createNode<EventDebugInfoNode>(buildContext, listOf(inputs)) + } + is NavTarget.ForwardEvent -> { + val inputs = ForwardMessagesNode.Inputs(navTarget.eventId) + val callback = object : ForwardMessagesNode.Callback { + override fun onForwardedToSingleRoom(roomId: RoomId) { + this@MessagesFlowNode.callback?.onForwardedToSingleRoom(roomId) + } + } + createNode<ForwardMessagesNode>(buildContext, listOf(inputs, callback)) + } + is NavTarget.ReportMessage -> { + val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) + createNode<ReportMessageNode>(buildContext, listOf(inputs)) + } + NavTarget.SendLocation -> { + sendLocationEntryPoint.createNode(this, buildContext) + } + } + } + + private fun processEventClicked(event: TimelineItem.Event) { + when (event.content) { + is TimelineItemImageContent -> { + val navTarget = NavTarget.MediaViewer( + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize, + fileExtension = event.content.fileExtension + ), + mediaSource = event.content.mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + backstack.push(navTarget) + } + is TimelineItemVideoContent -> { + val mediaSource = event.content.videoSource + val navTarget = NavTarget.MediaViewer( + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize, + fileExtension = event.content.fileExtension + ), + mediaSource = mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + backstack.push(navTarget) + } + is TimelineItemFileContent -> { + val mediaSource = event.content.fileSource + val navTarget = NavTarget.MediaViewer( + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize, + fileExtension = event.content.fileExtension + ), + mediaSource = mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + backstack.push(navTarget) + } + is TimelineItemAudioContent -> { + val mediaSource = event.content.audioSource + val navTarget = NavTarget.MediaViewer( + mediaInfo = MediaInfo( + name = event.content.body, + mimeType = event.content.mimeType, + formattedFileSize = event.content.formattedFileSize, + fileExtension = event.content.fileExtension + ), + mediaSource = mediaSource, + thumbnailSource = null, + ) + backstack.push(navTarget) + } + is TimelineItemLocationContent -> { + val navTarget = NavTarget.LocationViewer( + location = event.content.location, + description = event.content.description, + ) + backstack.push(navTarget) + } + else -> Unit + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt new file mode 100644 index 0000000000..a0517c59c4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +interface MessagesNavigator { + fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClicked(eventId: EventId) + fun onReportContentClicked(eventId: EventId, senderId: UserId) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt new file mode 100644 index 0000000000..3f201a8e4c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.extensions.toAnalyticsViewRoom +import kotlinx.collections.immutable.ImmutableList + +@ContributesNode(RoomScope::class) +class MessagesNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val room: MatrixRoom, + private val analyticsService: AnalyticsService, + private val presenterFactory: MessagesPresenter.Factory, +) : Node(buildContext, plugins = plugins), MessagesNavigator { + + private val presenter = presenterFactory.create(this) + private val callback = plugins<Callback>().firstOrNull() + + interface Callback : Plugin { + fun onRoomDetailsClicked() + fun onEventClicked(event: TimelineItem.Event) + fun onPreviewAttachments(attachments: ImmutableList<Attachment>) + fun onUserDataClicked(userId: UserId) + fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClicked(eventId: EventId) + fun onReportMessage(eventId: EventId, senderId: UserId) + fun onSendLocationClicked() + } + + init { + lifecycle.subscribe( + onCreate = { + analyticsService.capture(room.toAnalyticsViewRoom()) + } + ) + } + + private fun onRoomDetailsClicked() { + callback?.onRoomDetailsClicked() + } + + private fun onEventClicked(event: TimelineItem.Event) { + callback?.onEventClicked(event) + } + + private fun onPreviewAttachments(attachments: ImmutableList<Attachment>) { + callback?.onPreviewAttachments(attachments) + } + + private fun onUserDataClicked(userId: UserId) { + callback?.onUserDataClicked(userId) + } + override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback?.onShowEventDebugInfoClicked(eventId, debugInfo) + } + + override fun onForwardEventClicked(eventId: EventId) { + callback?.onForwardEventClicked(eventId) + } + + override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + callback?.onReportMessage(eventId, senderId) + } + + private fun onSendLocationClicked() { + callback?.onSendLocationClicked() + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MessagesView( + state = state, + onBackPressed = this::navigateUp, + onRoomDetailsClicked = this::onRoomDetailsClicked, + onEventClicked = this::onEventClicked, + onPreviewAttachments = this::onPreviewAttachments, + onUserDataClicked = this::onUserDataClicked, + onSendLocationClicked = this::onSendLocationClicked, + modifier = modifier, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt new file mode 100644 index 0000000000..a0a3e3a286 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents +import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.room.canSendMessageAsState +import io.element.android.libraries.textcomposer.MessageComposerMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +class MessagesPresenter @AssistedInject constructor( + private val room: MatrixRoom, + private val composerPresenter: MessageComposerPresenter, + private val timelinePresenter: TimelinePresenter, + private val actionListPresenter: ActionListPresenter, + private val customReactionPresenter: CustomReactionPresenter, + private val retrySendMenuPresenter: RetrySendMenuPresenter, + private val networkMonitor: NetworkMonitor, + private val snackbarDispatcher: SnackbarDispatcher, + private val messageSummaryFormatter: MessageSummaryFormatter, + private val dispatchers: CoroutineDispatchers, + private val clipboardHelper: ClipboardHelper, + @Assisted private val navigator: MessagesNavigator, +) : Presenter<MessagesState> { + + @AssistedFactory + interface Factory { + fun create(navigator: MessagesNavigator): MessagesPresenter + } + + @Composable + override fun present(): MessagesState { + val localCoroutineScope = rememberCoroutineScope() + val composerState = composerPresenter.present() + val timelineState = timelinePresenter.present() + val actionListState = actionListPresenter.present() + val customReactionState = customReactionPresenter.present() + val retryState = retrySendMenuPresenter.present() + + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) + val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) { + value = room.displayName + } + val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) { + value = room.avatarData() + } + var hasDismissedInviteDialog by rememberSaveable { + mutableStateOf(false) + } + + val inviteProgress = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) } + + val showReinvitePrompt by remember( + hasDismissedInviteDialog, + composerState.hasFocus, + syncUpdateFlow, + ) { + derivedStateOf { + !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L + } + } + + val networkConnectionStatus by networkMonitor.connectivity.collectAsState() + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + LaunchedEffect(composerState.mode.relatedEventId) { + timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId)) + } + + fun handleEvents(event: MessagesEvents) { + when (event) { + is MessagesEvents.HandleAction -> { + localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) + } + is MessagesEvents.ToggleReaction -> { + localCoroutineScope.toggleReaction(event.emoji, event.eventId) + } + is MessagesEvents.InviteDialogDismissed -> { + hasDismissedInviteDialog = true + + if (event.action == InviteDialogAction.Invite) { + localCoroutineScope.reinviteOtherUser(inviteProgress) + } + } + is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) + } + } + + return MessagesState( + roomId = room.roomId, + roomName = roomName, + roomAvatar = roomAvatar, + userHasPermissionToSendMessage = userHasPermissionToSendMessage, + composerState = composerState, + timelineState = timelineState, + actionListState = actionListState, + customReactionState = customReactionState, + retrySendMenuState = retryState, + hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, + snackbarMessage = snackbarMessage, + showReinvitePrompt = showReinvitePrompt, + inviteProgress = inviteProgress.value, + eventSink = ::handleEvents + ) + } + + private fun MatrixRoom.avatarData(): AvatarData { + return AvatarData( + id = roomId.value, + name = displayName, + url = avatarUrl, + size = AvatarSize.TimelineRoom + ) + } + + private fun CoroutineScope.handleTimelineAction( + action: TimelineItemAction, + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + ) = launch { + when (action) { + TimelineItemAction.Copy -> handleCopyContents(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState) + TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState) + TimelineItemAction.Developer -> handleShowDebugInfoAction(targetEvent) + TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.ReportContent -> handleReportAction(targetEvent) + } + } + + private fun CoroutineScope.toggleReaction( + emoji: String, + eventId: EventId, + ) = launch(dispatchers.io) { + room.toggleReaction(emoji, eventId) + .onFailure { Timber.e(it) } + } + + private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) { + suspend { + room.updateMembers() + + val memberList = when (val memberState = room.membersStateFlow.value) { + is MatrixRoomMembersState.Ready -> memberState.roomMembers + is MatrixRoomMembersState.Error -> memberState.prevRoomMembers.orEmpty() + else -> emptyList() + } + + val member = memberList.first { it.userId != room.sessionId } + room.inviteUserById(member.userId).onFailure { t -> + Timber.e(t, "Failed to reinvite DM partner") + }.getOrThrow() + }.runCatchingUpdatingState(inviteProgress) + } + + private suspend fun handleActionRedact(event: TimelineItem.Event) { + if (event.failedToSend) { + // If the message hasn't been sent yet, just cancel it + event.transactionId?.let { room.cancelSend(it) } + } else if (event.eventId != null) { + room.redactEvent(event.eventId) + } + } + + private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { + val composerMode = MessageComposerMode.Edit( + targetEvent.eventId, + (targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(), + targetEvent.transactionId, + ) + composerState.eventSink( + MessageComposerEvents.SetMode(composerMode) + ) + } + + private fun handleActionReply(targetEvent: TimelineItem.Event, composerState: MessageComposerState) { + if (targetEvent.eventId == null) return + val textContent = messageSummaryFormatter.format(targetEvent) + val attachmentThumbnailInfo = when (targetEvent.content) { + is TimelineItemImageContent -> AttachmentThumbnailInfo( + thumbnailSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Image, + blurHash = targetEvent.content.blurhash, + ) + is TimelineItemVideoContent -> AttachmentThumbnailInfo( + thumbnailSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Video, + blurHash = targetEvent.content.blurHash, + ) + is TimelineItemFileContent -> AttachmentThumbnailInfo( + thumbnailSource = targetEvent.content.thumbnailSource, + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.File, + ) + is TimelineItemAudioContent -> AttachmentThumbnailInfo( + textContent = targetEvent.content.body, + type = AttachmentThumbnailType.Audio, + ) + is TimelineItemLocationContent -> AttachmentThumbnailInfo( + type = AttachmentThumbnailType.Location, + ) + is TimelineItemTextBasedContent, + is TimelineItemRedactedContent, + is TimelineItemStateContent, + is TimelineItemEncryptedContent, + is TimelineItemUnknownContent -> null + } + val composerMode = MessageComposerMode.Reply( + senderName = targetEvent.safeSenderName, + eventId = targetEvent.eventId, + attachmentThumbnailInfo = attachmentThumbnailInfo, + defaultContent = textContent, + ) + composerState.eventSink( + MessageComposerEvents.SetMode(composerMode) + ) + } + + private fun handleShowDebugInfoAction(event: TimelineItem.Event) { + navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo) + } + + private fun handleForwardAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.onForwardEventClicked(event.eventId) + } + + private fun handleReportAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.onReportContentClicked(event.eventId, event.senderId) + } + + private suspend fun handleCopyContents(event: TimelineItem.Event) { + val content = when (event.content) { + is TimelineItemTextBasedContent -> event.content.body + is TimelineItemStateContent -> event.content.body + else -> return + } + + clipboardHelper.copyPlainText(content) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_message_copied)) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt new file mode 100644 index 0000000000..8a067a3a26 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.timeline.TimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId + +@Immutable +data class MessagesState( + val roomId: RoomId, + val roomName: String, + val roomAvatar: AvatarData, + val userHasPermissionToSendMessage: Boolean, + val composerState: MessageComposerState, + val timelineState: TimelineState, + val actionListState: ActionListState, + val customReactionState: CustomReactionState, + val retrySendMenuState: RetrySendMenuState, + val hasNetworkConnection: Boolean, + val snackbarMessage: SnackbarMessage?, + val inviteProgress: Async<Unit>, + val showReinvitePrompt: Boolean, + val eventSink: (MessagesEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt new file mode 100644 index 0000000000..d0ddcf68f4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.features.messages.impl.timeline.aTimelineItemList +import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.textcomposer.MessageComposerMode + +open class MessagesStateProvider : PreviewParameterProvider<MessagesState> { + override val values: Sequence<MessagesState> + get() = sequenceOf( + aMessagesState(), + aMessagesState().copy(hasNetworkConnection = false), + aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)), + aMessagesState().copy(userHasPermissionToSendMessage = false), + aMessagesState().copy(showReinvitePrompt = true), + ) +} + +fun aMessagesState() = MessagesState( + roomId = RoomId("!id:domain"), + roomName = "Room name", + roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom), + userHasPermissionToSendMessage = true, + composerState = aMessageComposerState().copy( + text = "Hello", + isFullScreen = false, + mode = MessageComposerMode.Normal("Hello"), + ), + timelineState = aTimelineState().copy( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + retrySendMenuState = RetrySendMenuState( + selectedEvent = null, + eventSink = {}, + ), + actionListState = anActionListState(), + customReactionState = CustomReactionState( + selectedEventId = null, + eventSink = {}, + ), + hasNetworkConnection = true, + snackbarMessage = null, + inviteProgress = Async.Uninitialized, + showReinvitePrompt = false, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt new file mode 100644 index 0000000000..6d8f2792e0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListView +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.messagecomposer.AttachmentsState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerView +import io.element.android.features.messages.impl.timeline.TimelineView +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView +import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.ProgressDialogType +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import timber.log.Timber + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MessagesView( + state: MessagesState, + onBackPressed: () -> Unit, + onRoomDetailsClicked: () -> Unit, + onEventClicked: (event: TimelineItem.Event) -> Unit, + onUserDataClicked: (UserId) -> Unit, + onPreviewAttachments: (ImmutableList<Attachment>) -> Unit, + onSendLocationClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + LogCompositions(tag = "MessagesScreen", msg = "Root") + + AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) + + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose + val localView = LocalView.current + + LogCompositions(tag = "MessagesScreen", msg = "Content") + + fun onMessageClicked(event: TimelineItem.Event) { + Timber.v("OnMessageClicked= ${event.id}") + onEventClicked(event) + } + + fun onMessageLongClicked(event: TimelineItem.Event) { + Timber.v("OnMessageLongClicked= ${event.id}") + localView.hideKeyboard() + state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) + } + + fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { + state.eventSink(MessagesEvents.HandleAction(action, event)) + } + + fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { + if (event.eventId == null) return + state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) + } + + fun onMoreReactionsClicked(event: TimelineItem.Event): Unit = + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) + + Scaffold( + modifier = modifier, + contentWindowInsets = WindowInsets.statusBars, + topBar = { + Column { + ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) + MessagesViewTopBar( + roomTitle = state.roomName, + roomAvatar = state.roomAvatar, + onBackPressed = onBackPressed, + onRoomDetailsClicked = onRoomDetailsClicked, + ) + } + }, + content = { padding -> + MessagesViewContent( + state = state, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + onMessageClicked = ::onMessageClicked, + onMessageLongClicked = ::onMessageLongClicked, + onUserDataClicked = onUserDataClicked, + onTimestampClicked = { event -> + if (event.localSendState is LocalEventSendState.SendingFailed) { + state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) + } + }, + onReactionClicked = ::onEmojiReactionClicked, + onMoreReactionsClicked = ::onMoreReactionsClicked, + onSendLocationClicked = onSendLocationClicked, + onSwipeToReply = { targetEvent -> + state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) + }, + ) + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + }, + ) + + ActionListView( + state = state.actionListState, + onActionSelected = ::onActionSelected, + onCustomReactionClicked = { event -> + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) + }, + onEmojiReactionClicked = ::onEmojiReactionClicked, + ) + + CustomReactionBottomSheet( + state = state.customReactionState, + onEmojiSelected = { emoji -> + state.customReactionState.selectedEventId?.let { eventId -> + state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + } + } + ) + + RetrySendMessageMenu( + state = state.retrySendMenuState + ) + + ReinviteDialog( + state = state + ) +} + +@Composable +fun ReinviteDialog(state: MessagesState) { + if (state.showReinvitePrompt) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_room_invite_again_alert_title), + content = stringResource(id = R.string.screen_room_invite_again_alert_message), + cancelText = stringResource(id = CommonStrings.action_cancel), + submitText = stringResource(id = CommonStrings.action_invite), + emphasizeSubmitButton = true, + onSubmitClicked = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, + onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } + ) + } +} + +@Composable +private fun AttachmentStateView( + state: AttachmentsState, + onPreviewAttachments: (ImmutableList<Attachment>) -> Unit +) { + when (state) { + AttachmentsState.None -> Unit + is AttachmentsState.Previewing -> LaunchedEffect(state) { + onPreviewAttachments(state.attachments) + } + is AttachmentsState.Sending -> { + ProgressDialog( + type = when (state) { + is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress) + is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate + }, + text = stringResource(id = CommonStrings.common_sending) + ) + } + } +} + +@Composable +fun MessagesViewContent( + state: MessagesState, + onMessageClicked: (TimelineItem.Event) -> Unit, + onUserDataClicked: (UserId) -> Unit, + onReactionClicked: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + onMessageLongClicked: (TimelineItem.Event) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + onSendLocationClicked: () -> Unit, + modifier: Modifier = Modifier, + onSwipeToReply: (TimelineItem.Event) -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + .imePadding() + ) { + // Hide timeline if composer is full screen + if (!state.composerState.isFullScreen) { + TimelineView( + state = state.timelineState, + modifier = Modifier.weight(1f), + onMessageClicked = onMessageClicked, + onMessageLongClicked = onMessageLongClicked, + onUserDataClicked = onUserDataClicked, + onTimestampClicked = onTimestampClicked, + onReactionClicked = onReactionClicked, + onMoreReactionsClicked = onMoreReactionsClicked, + onSwipeToReply = onSwipeToReply, + ) + } + if (state.userHasPermissionToSendMessage) { + MessageComposerView( + state = state.composerState, + onSendLocationClicked = onSendLocationClicked, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(Alignment.Bottom) + ) + } else { + CantSendMessageBanner() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessagesViewTopBar( + roomTitle: String, + roomAvatar: AvatarData, + modifier: Modifier = Modifier, + onRoomDetailsClicked: () -> Unit = {}, + onBackPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackPressed) + }, + title = { + Row( + modifier = Modifier.clickable { onRoomDetailsClicked() }, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(roomAvatar) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = roomTitle, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + windowInsets = WindowInsets(0.dp) + ) +} + +@Composable +fun CantSendMessageBanner( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondary) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.screen_room_no_permission_to_post), + color = MaterialTheme.colorScheme.onSecondary, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + fontStyle = FontStyle.Italic, + ) + } +} + +@Preview +@Composable +internal fun MessagesViewLightPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun MessagesViewDarkPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: MessagesState) { + MessagesView( + state = state, + onBackPressed = {}, + onRoomDetailsClicked = {}, + onEventClicked = {}, + onPreviewAttachments = {}, + onUserDataClicked = {}, + onSendLocationClicked = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt new file mode 100644 index 0000000000..a6244a72e3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface ActionListEvents { + object Clear : ActionListEvents + data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt new file mode 100644 index 0000000000..56e9f48dde --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.canBeCopied +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class ActionListPresenter @Inject constructor( + private val buildMeta: BuildMeta, +) : Presenter<ActionListState> { + + @Composable + override fun present(): ActionListState { + val localCoroutineScope = rememberCoroutineScope() + + val target: MutableState<ActionListState.Target> = remember { + mutableStateOf(ActionListState.Target.None) + } + + val displayEmojiReactions by remember { + derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.isRemote == true } + } + + fun handleEvents(event: ActionListEvents) { + when (event) { + ActionListEvents.Clear -> target.value = ActionListState.Target.None + is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.event, target) + } + } + + return ActionListState( + target = target.value, + displayEmojiReactions = displayEmojiReactions, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch { + target.value = ActionListState.Target.Loading(timelineItem) + val actions = + when (timelineItem.content) { + is TimelineItemRedactedContent -> { + if (buildMeta.isDebuggable) { + listOf(TimelineItemAction.Developer) + } else { + emptyList() + } + } + is TimelineItemStateContent -> { + buildList { + add(TimelineItemAction.Copy) + if (buildMeta.isDebuggable) { + add(TimelineItemAction.Developer) + } + } + } + else -> buildList<TimelineItemAction> { + if (timelineItem.isRemote) { + // Can only reply or forward messages already uploaded to the server + add(TimelineItemAction.Reply) + add(TimelineItemAction.Forward) + } + if (timelineItem.isMine && timelineItem.isTextMessage) { + add(TimelineItemAction.Edit) + } + if (timelineItem.content.canBeCopied()) { + add(TimelineItemAction.Copy) + } + if (buildMeta.isDebuggable) { + add(TimelineItemAction.Developer) + } + if (!timelineItem.isMine) { + add(TimelineItemAction.ReportContent) + } + if (timelineItem.isMine) { + add(TimelineItemAction.Redact) + } + } + } + if (actions.isNotEmpty()) { + target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList()) + } else { + target.value = ActionListState.Target.None + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt new file mode 100644 index 0000000000..aac3469218 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class ActionListState( + val target: Target, + val displayEmojiReactions: Boolean, + val eventSink: (ActionListEvents) -> Unit, +) { + sealed interface Target { + object None : Target + data class Loading(val event: TimelineItem.Event) : Target + data class Success( + val event: TimelineItem.Event, + val actions: ImmutableList<TimelineItemAction>, + ) : Target + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt new file mode 100644 index 0000000000..09213d64b3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class ActionListStateProvider : PreviewParameterProvider<ActionListState> { + override val values: Sequence<ActionListState> + get() { + val reactionsState = aTimelineItemReactions(1, isHighlighted = true) + return sequenceOf( + anActionListState(), + anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent().copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ) + ), + anActionListState().copy( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy( + reactionsState = reactionsState + ), + actions = aTimelineItemActionList(), + ), + displayEmojiReactions = false, + ), + ) + } +} + +fun anActionListState() = ActionListState( + target = ActionListState.Target.None, + displayEmojiReactions = true, + eventSink = {} +) + +fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> { + return persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + TimelineItemAction.Edit, + TimelineItemAction.Redact, + TimelineItemAction.ReportContent, + TimelineItemAction.Developer, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt new file mode 100644 index 0000000000..fd2ad94345 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ListItem +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionListView( + state: ActionListState, + onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, + onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, + onCustomReactionClicked: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + val targetItem = (state.target as? ActionListState.Target.Success)?.event + + fun onItemActionClicked( + itemAction: TimelineItemAction + ) { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onActionSelected(itemAction, targetItem) + } + } + + fun onEmojiReactionClicked(emoji: String) { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onEmojiReactionClicked(emoji, targetItem) + } + } + + fun onCustomReactionClicked() { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onCustomReactionClicked(targetItem) + } + } + + fun onDismiss() { + state.eventSink(ActionListEvents.Clear) + } + + if (targetItem != null) { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = ::onDismiss, + modifier = modifier, + ) { + SheetContent( + state = state, + onActionClicked = ::onItemActionClicked, + onEmojiReactionClicked = ::onEmojiReactionClicked, + onCustomReactionClicked = ::onCustomReactionClicked, + modifier = Modifier + .padding(bottom = 32.dp) +// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 +// .imePadding() + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SheetContent( + state: ActionListState, + onActionClicked: (TimelineItemAction) -> Unit, + onEmojiReactionClicked: (String) -> Unit, + onCustomReactionClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + when (val target = state.target) { + is ActionListState.Target.Loading, + ActionListState.Target.None -> { + // Crashes if sheetContent size is zero + Box(modifier = modifier.size(1.dp)) + } + + is ActionListState.Target.Success -> { + val actions = target.actions + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + item { + Column { + MessageSummary( + event = target.event, modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(14.dp)) + Divider() + } + } + if (state.displayEmojiReactions) { + item { + EmojiReactionsRow( + highlightedEmojis = target.event.reactionsState.highlightedKeys, + onEmojiReactionClicked = onEmojiReactionClicked, + onCustomReactionClicked = onCustomReactionClicked, + modifier = Modifier.fillMaxWidth(), + ) + Divider() + } + } + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { + onActionClicked(action) + }, + text = { + Text( + text = stringResource(id = action.titleRes), + color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + }, + icon = { + Icon( + resourceId = action.icon, + contentDescription = "", + tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + } + ) + } + } + } + } +} + +@Composable +private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modifier) { + val content: @Composable () -> Unit + var icon: @Composable () -> Unit = { Avatar(avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender)) } + val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = MaterialTheme.colorScheme.secondary) + val imageModifier = Modifier + .size(AvatarSize.MessageActionSender.dp) + .clip(RoundedCornerShape(9.dp)) + + @Composable + fun ContentForBody(body: String) { + Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + val context = LocalContext.current + val formatter = remember(context) { MessageSummaryFormatterImpl(context) } + val textContent = remember(event.content) { formatter.format(event) } + + when (event.content) { + is TimelineItemTextBasedContent, + is TimelineItemStateContent, + is TimelineItemEncryptedContent, + is TimelineItemRedactedContent, + is TimelineItemUnknownContent -> content = { ContentForBody(textContent) } + is TimelineItemLocationContent -> { + icon = { + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + type = AttachmentThumbnailType.Location, + textContent = stringResource(CommonStrings.common_shared_location), + ) + ) + } + content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) } + } + is TimelineItemImageContent -> { + icon = { + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + thumbnailSource = event.content.mediaSource, + textContent = textContent, + type = AttachmentThumbnailType.Image, + blurHash = event.content.blurhash, + ) + ) + } + content = { ContentForBody(event.content.body) } + } + is TimelineItemVideoContent -> { + icon = { + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + thumbnailSource = event.content.thumbnailSource, + textContent = textContent, + type = AttachmentThumbnailType.Video, + blurHash = event.content.blurHash, + ) + ) + } + content = { ContentForBody(event.content.body) } + } + is TimelineItemFileContent -> { + icon = { + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + thumbnailSource = event.content.thumbnailSource, + textContent = textContent, + type = AttachmentThumbnailType.File, + ) + ) + } + content = { ContentForBody(event.content.body) } + } + is TimelineItemAudioContent -> { + icon = { + AttachmentThumbnail( + modifier = imageModifier, + info = AttachmentThumbnailInfo( + textContent = textContent, + type = AttachmentThumbnailType.Audio, + ) + ) + } + content = { ContentForBody(event.content.body) } + } + } + Row(modifier = modifier) { + icon() + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Row { + if (event.senderDisplayName != null) { + Text( + text = event.senderDisplayName, + style = ElementTheme.typography.fontBodySmMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + content() + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + event.sentTime, + style = ElementTheme.typography.fontBodyXsRegular, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.End, + ) + } +} + +private val emojiRippleRadius = 24.dp + +@Composable +internal fun EmojiReactionsRow( + highlightedEmojis: ImmutableList<String>, + onEmojiReactionClicked: (String) -> Unit, + onCustomReactionClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp) + ) { + // TODO use most recently used emojis here when available from the Rust SDK + val defaultEmojis = sequenceOf( + "👍", "👎", "🔥", "❤️", "👏" + ) + for (emoji in defaultEmojis) { + val isHighlighted = highlightedEmojis.contains(emoji) + EmojiButton(emoji, isHighlighted, onEmojiReactionClicked) + } + + Icon( + imageVector = Icons.Outlined.AddReaction, + contentDescription = "Emojis", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + .clickable( + enabled = true, + onClick = onCustomReactionClicked, + indication = rememberRipple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + ) + } +} + +@Composable +private fun EmojiButton( + emoji: String, + isHighlighted: Boolean, + onClicked: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isHighlighted) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + Box( + modifier = modifier + .size(48.dp) + .background(backgroundColor, RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + Text( + emoji, + fontSize = 28.dp.toSp(), + color = Color.White, + modifier = Modifier + .clickable( + enabled = true, + onClick = { onClicked(emoji) }, + indication = rememberRipple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + ) + } +} + +@DayNightPreviews +@Composable +fun SheetContentPreview( + @PreviewParameter(ActionListStateProvider::class) state: ActionListState +) = ElementPreview { + SheetContent( + state = state, + onActionClicked = {}, + onEmojiReactionClicked = {}, + onCustomReactionClicked = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt new file mode 100644 index 0000000000..8b2922e1d6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.actionlist.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +sealed class TimelineItemAction( + @StringRes val titleRes: Int, + @DrawableRes val icon: Int, + val destructive: Boolean = false +) { + object Forward : TimelineItemAction(CommonStrings.action_forward, VectorIcons.Forward) + object Copy : TimelineItemAction(CommonStrings.action_copy, VectorIcons.Copy) + object Redact : TimelineItemAction(CommonStrings.action_remove, VectorIcons.Delete, destructive = true) + object Reply : TimelineItemAction(CommonStrings.action_reply, VectorIcons.Reply) + object Edit : TimelineItemAction(CommonStrings.action_edit, VectorIcons.Edit) + object Developer : TimelineItemAction(CommonStrings.action_view_source, VectorIcons.DeveloperMode) + object ReportContent : TimelineItemAction(CommonStrings.action_report_content, VectorIcons.ReportContent, destructive = true) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt new file mode 100644 index 0000000000..8739a45201 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.media.local.LocalMedia +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface Attachment : Parcelable { + + @Parcelize + data class Media(val localMedia: LocalMedia, val compressIfPossible: Boolean) : Attachment +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt new file mode 100644 index 0000000000..14a6a3fb2d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface AttachmentsPreviewEvents { + object SendAttachment : AttachmentsPreviewEvents + object ClearSendState : AttachmentsPreviewEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt new file mode 100644 index 0000000000..a5dc90f02f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.theme.ForcedDarkElementTheme +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +class AttachmentsPreviewNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: AttachmentsPreviewPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs(val attachment: Attachment) : NodeInputs + + private val inputs: Inputs = inputs() + + private val presenter = presenterFactory.create(inputs.attachment) + + @Composable + override fun View(modifier: Modifier) { + ForcedDarkElementTheme { + val state = presenter.present() + AttachmentsPreviewView( + state = state, + onDismiss = this::navigateUp, + modifier = modifier + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt new file mode 100644 index 0000000000..3ee87c0bc8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.mediaupload.api.MediaSender +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class AttachmentsPreviewPresenter @AssistedInject constructor( + @Assisted private val attachment: Attachment, + private val mediaSender: MediaSender, +) : Presenter<AttachmentsPreviewState> { + + @AssistedFactory + interface Factory { + fun create(attachment: Attachment): AttachmentsPreviewPresenter + } + + @Composable + override fun present(): AttachmentsPreviewState { + + val coroutineScope = rememberCoroutineScope() + + val sendActionState = remember { + mutableStateOf<SendActionState>(SendActionState.Idle) + } + + fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { + when (attachmentsPreviewEvents) { + AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState) + AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = SendActionState.Idle + } + } + + return AttachmentsPreviewState( + attachment = attachment, + sendActionState = sendActionState.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.sendAttachment( + attachment: Attachment, + sendActionState: MutableState<SendActionState>, + ) = launch { + when (attachment) { + is Attachment.Media -> { + sendMedia( + mediaAttachment = attachment, + sendActionState = sendActionState + ) + } + } + } + + private suspend fun sendMedia( + mediaAttachment: Attachment.Media, + sendActionState: MutableState<SendActionState>, + ) { + val progressCallback = object : ProgressCallback { + override fun onProgress(current: Long, total: Long) { + sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat()) + } + } + sendActionState.value = SendActionState.Sending.Processing + mediaSender.sendMedia( + uri = mediaAttachment.localMedia.uri, + mimeType = mediaAttachment.localMedia.info.mimeType, + compressIfPossible = mediaAttachment.compressIfPossible, + progressCallback = progressCallback + ).fold( + onSuccess = { + sendActionState.value = SendActionState.Done + }, + onFailure = { + sendActionState.value = SendActionState.Failure(it) + } + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt new file mode 100644 index 0000000000..e41f43040f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import io.element.android.features.messages.impl.attachments.Attachment + +data class AttachmentsPreviewState( + val attachment: Attachment, + val sendActionState: SendActionState, + val eventSink: (AttachmentsPreviewEvents) -> Unit +) + +sealed interface SendActionState { + object Idle : SendActionState + sealed interface Sending : SendActionState { + object Processing : Sending + data class Uploading(val progress: Float) : Sending + } + + data class Failure(val error: Throwable) : SendActionState + object Done : SendActionState +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt new file mode 100644 index 0000000000..ee41ace4b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.core.net.toUri +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.anImageInfo + +open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { + override val values: Sequence<AttachmentsPreviewState> + get() = sequenceOf( + anAttachmentsPreviewState(), + anAttachmentsPreviewState(mediaInfo = aFileInfo()), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), + anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException())), + ) +} + +fun anAttachmentsPreviewState( + mediaInfo: MediaInfo = anImageInfo(), + sendActionState: SendActionState = SendActionState.Idle) = AttachmentsPreviewState( + attachment = Attachment.Media( + localMedia = LocalMedia("file://path".toUri(), mediaInfo), + compressIfPossible = true + ), + sendActionState = sendActionState, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt new file mode 100644 index 0000000000..6f33ef5d0b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError +import io.element.android.features.messages.impl.media.local.LocalMediaView +import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.ProgressDialogType +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AttachmentsPreviewView( + state: AttachmentsPreviewState, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + + fun postSendAttachment() { + state.eventSink(AttachmentsPreviewEvents.SendAttachment) + } + + fun postClearSendState() { + state.eventSink(AttachmentsPreviewEvents.ClearSendState) + } + + if (state.sendActionState is SendActionState.Done) { + LaunchedEffect(state.sendActionState) { + onDismiss() + } + } + + Scaffold(modifier) { + Box( + modifier = Modifier.padding(it), + contentAlignment = Alignment.Center + ) { + AttachmentPreviewContent( + attachment = state.attachment, + onSendClicked = ::postSendAttachment, + onDismiss = onDismiss + ) + } + } + AttachmentSendStateView( + sendActionState = state.sendActionState, + onDismissClicked = ::postClearSendState, + onRetryClicked = ::postSendAttachment + ) +} + +@Composable +private fun AttachmentSendStateView( + sendActionState: SendActionState, + onDismissClicked: () -> Unit, + onRetryClicked: () -> Unit +) { + + when (sendActionState) { + is SendActionState.Sending -> { + ProgressDialog( + type = when (sendActionState) { + is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress) + SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate + }, + text = stringResource(id = CommonStrings.common_sending) + ) + } + is SendActionState.Failure -> { + RetryDialog( + content = stringResource(sendAttachmentError(sendActionState.error)), + onDismiss = onDismissClicked, + onRetry = onRetryClicked + ) + } + else -> Unit + } +} + +@Composable +private fun AttachmentPreviewContent( + attachment: Attachment, + onSendClicked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = 24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + when (attachment) { + is Attachment.Media -> LocalMediaView( + localMedia = attachment.localMedia + ) + } + } + AttachmentsPreviewBottomActions( + onCancelClicked = onDismiss, + onSendClicked = onSendClicked, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 120.dp) + .padding(all = 24.dp) + ) + } +} + +@Composable +private fun AttachmentsPreviewBottomActions( + onCancelClicked: () -> Unit, + onSendClicked: () -> Unit, + modifier: Modifier = Modifier +) { + ButtonRowMolecule( + modifier = modifier, + ) { + TextButton(onClick = onCancelClicked) { + Text(stringResource(id = CommonStrings.action_cancel)) + } + TextButton(onClick = onSendClicked) { + Text(stringResource(id = CommonStrings.action_send)) + } + } +} + +@Preview +@Composable +fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: AttachmentsPreviewState) { + AttachmentsPreviewView( + state = state, + onDismiss = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt new file mode 100644 index 0000000000..7ef86e4817 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.attachments.preview.error + +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.ui.strings.CommonStrings + +fun sendAttachmentError( + throwable: Throwable +): Int { + return if (throwable is MediaPreProcessor.Failure) { + CommonStrings.screen_media_upload_preview_error_failed_processing + } else { + CommonStrings.screen_media_upload_preview_error_failed_sending + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt new file mode 100644 index 0000000000..6b74918d71 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails + +sealed interface ForwardMessagesEvents { + data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents + // TODO remove to restore multi-selection + object RemoveSelectedRoom : ForwardMessagesEvents + object ToggleSearchActive : ForwardMessagesEvents + data class UpdateQuery(val query: String) : ForwardMessagesEvents + object ForwardEvent : ForwardMessagesEvents + object ClearError : ForwardMessagesEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt new file mode 100644 index 0000000000..13d26b9881 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList + +@ContributesNode(RoomScope::class) +class ForwardMessagesNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: ForwardMessagesPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onForwardedToSingleRoom(roomId: RoomId) + } + + data class Inputs(val eventId: EventId) : NodeInputs + + private val inputs = inputs<Inputs>() + private val presenter = presenterFactory.create(inputs.eventId.value) + private val callbacks = plugins.filterIsInstance<Callback>() + + private fun onSucceeded(roomIds: ImmutableList<RoomId>) { + navigateUp() + if (roomIds.size == 1) { + val targetRoomId = roomIds.first() + callbacks.forEach { it.onForwardedToSingleRoom(targetRoomId) } + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ForwardMessagesView( + state = state, + onDismiss = ::navigateUp, + onForwardingSucceeded = ::onSucceeded, + modifier = modifier + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt new file mode 100644 index 0000000000..e1d7ed3e7e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ForwardMessagesPresenter @AssistedInject constructor( + @Assisted eventId: String, + private val room: MatrixRoom, + private val matrixCoroutineScope: CoroutineScope, + private val client: MatrixClient, +) : Presenter<ForwardMessagesState> { + + private val eventId: EventId = EventId(eventId) + + @AssistedFactory + interface Factory { + fun create(eventId: String): ForwardMessagesPresenter + } + + @Composable + override fun present(): ForwardMessagesState { + var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) } + var query by remember { mutableStateOf<String>("") } + var isSearchActive by remember { mutableStateOf(false) } + var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } + val forwardingActionState: MutableState<Async<ImmutableList<RoomId>>> = remember { mutableStateOf(Async.Uninitialized) } + + val summaries by client.roomSummaryDataSource.allRooms().collectAsState() + + LaunchedEffect(query, summaries) { + val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>() + .map { it.details } + .filter { it.name.contains(query, ignoreCase = true) } + .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received + .toPersistentList() + results = if (filteredSummaries.isNotEmpty()) { + SearchBarResultState.Results(filteredSummaries) + } else { + SearchBarResultState.NoResults() + } + } + + val forwardingSucceeded by remember { + derivedStateOf { forwardingActionState.value.dataOrNull() } + } + + fun handleEvents(event: ForwardMessagesEvents) { + when (event) { + is ForwardMessagesEvents.SetSelectedRoom -> { + selectedRooms = persistentListOf(event.room) + // Restore for multi-selection +// val index = selectedRooms.indexOfFirst { it.roomId == event.room.roomId } +// selectedRooms = if (index >= 0) { +// selectedRooms.removeAt(index) +// } else { +// selectedRooms.add(event.room) +// } + } + ForwardMessagesEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() + is ForwardMessagesEvents.UpdateQuery -> query = event.query + ForwardMessagesEvents.ToggleSearchActive -> isSearchActive = !isSearchActive + ForwardMessagesEvents.ForwardEvent -> { + isSearchActive = false + val roomIds = selectedRooms.map { it.roomId }.toPersistentList() + matrixCoroutineScope.forwardEvent(eventId, roomIds, forwardingActionState) + } + ForwardMessagesEvents.ClearError -> forwardingActionState.value = Async.Uninitialized + } + } + + return ForwardMessagesState( + resultState = results, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + isForwarding = forwardingActionState.value.isLoading(), + error = (forwardingActionState.value as? Async.Failure)?.error, + forwardingSucceeded = forwardingSucceeded, + eventSink = { handleEvents(it) } + ) + } + + private fun CoroutineScope.forwardEvent( + eventId: EventId, + roomIds: ImmutableList<RoomId>, + isForwardMessagesState: MutableState<Async<ImmutableList<RoomId>>>, + ) = launch { + isForwardMessagesState.value = Async.Loading() + room.forwardEvent(eventId, roomIds).fold( + { isForwardMessagesState.value = Async.Success(roomIds) }, + { isForwardMessagesState.value = Async.Failure(it) } + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt new file mode 100644 index 0000000000..7540766097 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import kotlinx.collections.immutable.ImmutableList + +data class ForwardMessagesState( + val resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>>, + val query: String, + val isSearchActive: Boolean, + val selectedRooms: ImmutableList<RoomSummaryDetails>, + val isForwarding: Boolean, + val error: Throwable?, + val forwardingSucceeded: ImmutableList<RoomId>?, + val eventSink: (ForwardMessagesEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt new file mode 100644 index 0000000000..75aacea616 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> { + override val values: Sequence<ForwardMessagesState> + get() = sequenceOf( + aForwardMessagesState(), + aForwardMessagesState(query = "Test"), + aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), + aForwardMessagesState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), query = "Test"), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))) + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + isForwarding = true, + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), + ), + aForwardMessagesState( + resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + query = "Test", + selectedRooms = persistentListOf(aRoomDetailsState(roomId = RoomId("!room2:domain"))), + error = Throwable("error"), + ), + // Add other states here + ) +} + +fun aForwardMessagesState( + resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(), + query: String = "", + isSearchActive: Boolean = false, + selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(), + isForwarding: Boolean = false, + error: Throwable? = null, + forwardingSucceeded: ImmutableList<RoomId>? = null, +) = ForwardMessagesState( + resultState = resultState, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + isForwarding = isForwarding, + error = error, + forwardingSucceeded = forwardingSucceeded, + eventSink = {} +) + +internal fun aForwardMessagesRoomList() = listOf( + aRoomDetailsState(), + aRoomDetailsState(roomId = RoomId("!room2:domain"), canonicalAlias = "#element-x-room:matrix.org"), +) + +fun aRoomDetailsState( + roomId: RoomId = RoomId("!room:domain"), + name: String = "roomName", + canonicalAlias: String? = null, + isDirect: Boolean = true, + avatarURLString: String? = null, + lastMessage: RoomMessage? = null, + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 0, + inviter: RoomMember? = null, +) = RoomSummaryDetails( + roomId = roomId, + name = name, + canonicalAlias = canonicalAlias, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, + inviter = inviter, + ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt new file mode 100644 index 0000000000..230965312e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.forward + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.RadioButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.roomListRoomMessage +import io.element.android.libraries.designsystem.theme.roomListRoomName +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.ui.components.SelectedRoom +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ForwardMessagesView( + state: ForwardMessagesState, + onDismiss: () -> Unit, + onForwardingSucceeded: (ImmutableList<RoomId>) -> Unit, + modifier: Modifier = Modifier, +) { + if (state.forwardingSucceeded != null) { + onForwardingSucceeded(state.forwardingSucceeded) + return + } + + fun onRoomRemoved(roomSummaryDetails: RoomSummaryDetails) { + // TODO toggle selection when multi-selection is enabled + state.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) + } + + @Composable + fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList<RoomSummaryDetails>) { + if (isForwarding) return + SelectedRooms( + selectedRooms = selectedRooms, + onRoomRemoved = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + fun onBackButton(state: ForwardMessagesState) { + if (state.isSearchActive) { + state.eventSink(ForwardMessagesEvents.ToggleSearchActive) + } else { + onDismiss() + } + } + + BackHandler(onBack = { onBackButton(state) }) + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(CommonStrings.common_forward_message), + style = ElementTheme.typography.aliasScreenTitle + ) + }, + navigationIcon = { + BackButton(onClick = { onBackButton(state) }) + }, + actions = { + TextButton( + enabled = state.selectedRooms.isNotEmpty(), + onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) } + ) { + Text(text = stringResource(CommonStrings.action_send)) + } + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar<ImmutableList<RoomSummaryDetails>>( + placeHolderTitle = stringResource(CommonStrings.action_search), + query = state.query, + onQueryChange = { state.eventSink(ForwardMessagesEvents.UpdateQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(ForwardMessagesEvents.ToggleSearchActive) }, + resultState = state.resultState, + showBackButton = false, + ) { summaries -> + LazyColumn { + item { + SelectedRoomsHelper( + isForwarding = state.isForwarding, + selectedRooms = state.selectedRooms + ) + } + items(summaries, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) + } + ) + Divider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + + if (!state.isSearchActive) { + // TODO restore for multi-selection +// SelectedRoomsHelper( +// isForwarding = state.isForwarding, +// selectedRooms = state.selectedRooms +// ) + Spacer(modifier = Modifier.height(20.dp)) + + if (state.resultState is SearchBarResultState.Results) { + LazyColumn { + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> + Column { + RoomSummaryView( + roomSummary, + isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, + onSelection = { roomSummary -> + state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) + } + ) + Divider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + + if (state.isForwarding) { + ProgressDialog() + } + + if (state.error != null) { + ForwardingErrorDialog(onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }) + } + } + } +} + +@Composable +internal fun SelectedRooms( + selectedRooms: ImmutableList<RoomSummaryDetails>, + onRoomRemoved: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(selectedRooms, key = { it.roomId.value }) { roomSummary -> + SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) + } + } +} + +@Composable +internal fun RoomSummaryView( + summary: RoomSummaryDetails, + isSelected: Boolean, + onSelection: (RoomSummaryDetails) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onSelection(summary) } + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .heightIn(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val roomAlias = summary.canonicalAlias ?: summary.roomId.value + Avatar( + avatarData = AvatarData( + id = roomAlias, + name = summary.name, + url = summary.avatarURLString, + size = AvatarSize.ForwardRoomListItem, + ), + ) + Column( + modifier = Modifier + .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) + .weight(1f) + ) { + // Name + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = summary.name, + color = MaterialTheme.roomListRoomName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Id + Text( + text = roomAlias, + color = MaterialTheme.roomListRoomMessage(), + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + RadioButton(selected = isSelected, onClick = { onSelection(summary) }) + } +} + +@Composable +private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) { + ErrorDialog( + content = ErrorDialogDefaults.title, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +@Preview +@Composable +fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ForwardMessagesState) { + ForwardMessagesView( + state = state, + onDismiss = {}, + onForwardingSucceeded = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt new file mode 100644 index 0000000000..251d7a0f06 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/helper/fileExtensionAndSize.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.helper + +fun formatFileExtensionAndSize(extension: String, size: String?): String { + return buildString { + append(extension.uppercase()) + if (size != null) { + append(' ') + append("($size)") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt new file mode 100644 index 0000000000..44bff4f5ab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import androidx.core.net.toFile +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidLocalMediaActions @Inject constructor( + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, + private val buildMeta: BuildMeta, +) : LocalMediaActions { + + private var activityContext: Context? = null + + @Composable + override fun Configure() { + val context = LocalContext.current + return DisposableEffect(Unit) { + activityContext = context + onDispose { + activityContext = null + } + } + } + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveOnDiskUsingMediaStore(localMedia) + } else { + saveOnDiskUsingExternalStorageApi(localMedia) + } + }.onSuccess { + Timber.v("Save on disk succeed") + }.onFailure { + Timber.e(it, "Save on disk failed") + } + } + + override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatching { + val shareableUri = localMedia.toShareableUri() + val shareMediaIntent = Intent(Intent.ACTION_SEND) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, shareableUri) + .setTypeAndNormalize(localMedia.info.mimeType) + withContext(coroutineDispatchers.main) { + val intent = Intent.createChooser(shareMediaIntent, null) + activityContext!!.startActivity(intent) + } + }.onSuccess { + Timber.v("Share media succeed") + }.onFailure { + Timber.e(it, "Share media failed") + } + } + + override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatching { + val openMediaIntent = Intent(Intent.ACTION_VIEW) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) + withContext(coroutineDispatchers.main) { + activityContext!!.startActivity(openMediaIntent) + } + }.onSuccess { + Timber.v("Open media succeed") + }.onFailure { + Timber.e(it, "Open media failed") + } + } + + private fun LocalMedia.toShareableUri(): Uri { + val mediaAsFile = this.toFile() + val authority = "${buildMeta.applicationId}.fileprovider" + return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme() + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.name) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + val resolver = context.contentResolver + val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (outputUri != null) { + localMedia.openStream()?.use { input -> + resolver.openOutputStream(outputUri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) + } + } + } + } + + private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) { + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + localMedia.info.name + ) + localMedia.openStream()?.use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } + } + + private fun LocalMedia.openStream(): InputStream? { + return context.contentResolver.openInputStream(uri) + } + + /** + * Tries to extract a file from the uri. + */ + private fun LocalMedia.toFile(): File { + return uri.toFile() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt new file mode 100644 index 0000000000..ff2f8aaeeb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaFactory.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.androidutils.file.getFileName +import io.element.android.libraries.androidutils.file.getFileSize +import io.element.android.libraries.androidutils.file.getMimeType +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.toFile +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidLocalMediaFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, +) : LocalMediaFactory { + + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + val uri = mediaFile.toFile().toUri() + return createFromUri( + uri = uri, + mimeType = mediaInfo.mimeType, + name = mediaInfo.name, + formattedFileSize = mediaInfo.formattedFileSize, + ) + } + + override fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia { + val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream + val fileName = name ?: context.getFileName(uri) ?: "" + val fileSize = formattedFileSize ?: fileSizeFormatter.format(context.getFileSize(uri)) + val fileExtension = fileExtensionExtractor.extractFromName(fileName) + return LocalMedia( + uri = uri, + info = MediaInfo( + mimeType = resolvedMimeType, + name = fileName, + formattedFileSize = fileSize, + fileExtension = fileExtension + ) + ) + } +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt new file mode 100644 index 0000000000..549842428a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMedia.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.net.Uri +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +@Parcelize +@Immutable +data class LocalMedia( + val uri: Uri, + val info: MediaInfo, +) : Parcelable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt new file mode 100644 index 0000000000..f35af36057 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import androidx.compose.runtime.Composable + +interface LocalMediaActions { + + @Composable + fun Configure() + + /** + * Will save the current media to the Downloads directory. + * The [LocalMedia.uri] needs to have a file scheme. + */ + suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> + + /** + * Will try to find a suitable application to share the media with. + * The [LocalMedia.uri] needs to have a file scheme. + */ + suspend fun share(localMedia: LocalMedia): Result<Unit> + + /** + * Will try to find a suitable application to open the media with. + * The [LocalMedia.uri] needs to have a file scheme. + */ + suspend fun open(localMedia: LocalMedia): Result<Unit> +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt new file mode 100644 index 0000000000..36852a5a80 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaFactory.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.net.Uri +import io.element.android.libraries.matrix.api.media.MediaFile + +interface LocalMediaFactory { + + /** + * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo]. + */ + fun createFromMediaFile( + mediaFile: MediaFile, + mediaInfo: MediaInfo, + ): LocalMedia + + /** + * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize + * If any of those params are null, it'll try to read them from the content. + */ + fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt new file mode 100644 index 0000000000..ff17029497 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize +import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper +import io.element.android.features.messages.impl.media.local.pdf.PdfViewer +import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.theme.ElementTheme +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.ZoomableState +import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.rememberZoomableImageState +import me.saket.telephoto.zoomable.rememberZoomableState + +@SuppressLint("UnsafeOptInUsageError") +@Composable +fun LocalMediaView( + localMedia: LocalMedia?, + modifier: Modifier = Modifier, + localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), + mediaInfo: MediaInfo? = localMedia?.info, +) { + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 5f) + ) + val mimeType = mediaInfo?.mimeType + when { + mimeType.isMimeTypeImage() -> MediaImageView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + zoomableState = zoomableState, + modifier = modifier + ) + mimeType.isMimeTypeVideo() -> MediaVideoView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + modifier = modifier + ) + mimeType == MimeTypes.Pdf -> MediaPDFView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + zoomableState = zoomableState, + modifier = modifier + ) + //TODO handle audio with exoplayer + else -> MediaFileView( + localMediaViewState = localMediaViewState, + uri = localMedia?.uri, + info = mediaInfo, + modifier = modifier + ) + } +} + +@Composable +private fun MediaImageView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + zoomableState: ZoomableState, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = R.drawable.sample_background), + modifier = modifier.fillMaxSize(), + contentDescription = null, + ) + } else { + val zoomableImageState = rememberZoomableImageState(zoomableState) + localMediaViewState.isReady = zoomableImageState.isImageDisplayed + ZoomableAsyncImage( + modifier = modifier.fillMaxSize(), + state = zoomableImageState, + model = localMedia?.uri, + contentDescription = "Image", + contentScale = ContentScale.Fit, + ) + } +} + +@UnstableApi +@Composable +fun MediaVideoView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val playerListener = object : Player.Listener { + override fun onRenderedFirstFrame() { + localMediaViewState.isReady = true + } + } + val exoPlayer = remember { + ExoPlayerWrapper.create(context) + .apply { + addListener(playerListener) + this.prepare() + } + } + if (localMedia?.uri != null) { + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) + exoPlayer.setMediaItem(mediaItem) + } + } else { + exoPlayer.setMediaItems(emptyList()) + } + AndroidView( + factory = { + PlayerView(context).apply { + player = exoPlayer + setShowPreviousButton(false) + setShowNextButton(false) + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + controllerShowTimeoutMs = 3000 + } + }, + modifier = modifier.fillMaxSize() + ) + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> exoPlayer.play() + Lifecycle.Event.ON_PAUSE -> exoPlayer.pause() + Lifecycle.Event.ON_DESTROY -> { + exoPlayer.release() + exoPlayer.removeListener(playerListener) + } + else -> Unit + } + } +} + +@Composable +fun MediaPDFView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + zoomableState: ZoomableState, + modifier: Modifier = Modifier, +) { + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.uri, + zoomableState = zoomableState + ) + localMediaViewState.isReady = pdfViewerState.isLoaded + PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) +} + +@Composable +fun MediaFileView( + localMediaViewState: LocalMediaViewState, + uri: Uri?, + info: MediaInfo?, + modifier: Modifier = Modifier, +) { + val isAudio = info?.mimeType.isMimeTypeAudio().orFalse() + localMediaViewState.isReady = uri != null + Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment, + contentDescription = null, + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(32.dp) + .rotate(if (isAudio) 0f else -45f), + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = info.name, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize), + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt new file mode 100644 index 0000000000..e009c3f6cc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@Stable +class LocalMediaViewState { + var isReady: Boolean by mutableStateOf(false) +} + +@Composable +fun rememberLocalMediaViewState(): LocalMediaViewState { + return remember { + LocalMediaViewState() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt new file mode 100644 index 0000000000..af0f142bd8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/MediaInfo.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local + +import android.os.Parcelable +import io.element.android.libraries.core.mimetype.MimeTypes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MediaInfo( + val name: String, + val mimeType: String, + val formattedFileSize: String, + val fileExtension: String, +) : Parcelable + +fun anImageInfo(): MediaInfo = MediaInfo( + "an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg" +) + +fun aVideoInfo(): MediaInfo = MediaInfo( + "a video file.mp4", MimeTypes.Mp4, "14MB", "mp4" +) + +fun aPdfInfo(): MediaInfo = MediaInfo( + "a pdf file.pdf", MimeTypes.Pdf, "23MB", "pdf" +) + +fun aFileInfo(): MediaInfo = MediaInfo( + "an apk file.apk", MimeTypes.Apk, "50MB", "apk" +) + +fun anAudioInfo(): MediaInfo = MediaInfo( + "an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt new file mode 100644 index 0000000000..a69db1ef2c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/exoplayer/ExoPlayerWrapper.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.exoplayer + +import android.content.Context +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer + +/** + * Wrapper around ExoPlayer to disable some commands. + * Necessary to hide the settings wheels from the player. + */ +@UnstableApi +class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer { + + override fun isCommandAvailable(command: Int): Boolean { + return availableCommands.contains(command) + } + + override fun getAvailableCommands(): Player.Commands { + return exoPlayer.availableCommands + .buildUpon() + .remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS) + .build() + } + + companion object { + fun create(context: Context): ExoPlayer { + return ExoPlayerWrapper( + ExoPlayer.Builder(context).build() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt new file mode 100644 index 0000000000..22233b313f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/ParcelFileDescriptorFactory.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.pdf + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.File + +class ParcelFileDescriptorFactory(private val context: Context) { + + fun create(model: Any?) = runCatching { + when (model) { + is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY) + is Uri -> context.contentResolver.openFileDescriptor(model, "r")!! + else -> error(RuntimeException("Can't handle this model")) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt new file mode 100644 index 0000000000..0b8caed968 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfPage.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.pdf + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +@Stable +class PdfPage( + maxWidth: Int, + val pageIndex: Int, + private val mutex: Mutex, + private val pdfRenderer: PdfRenderer, + private val coroutineScope: CoroutineScope, +) { + + sealed interface State { + data class Loading(val width: Int, val height: Int) : State + data class Loaded(val bitmap: Bitmap) : State + } + + private val renderWidth = maxWidth + private val renderHeight: Int + private var loadJob: Job? = null + + init { + // We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions. + pdfRenderer.openPage(pageIndex).use { page -> + renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt() + } + } + + private val mutableStateFlow = MutableStateFlow<State>( + State.Loading( + width = renderWidth, + height = renderHeight + ) + ) + val stateFlow: StateFlow<State> = mutableStateFlow + + fun load() { + loadJob = coroutineScope.launch { + val bitmap = mutex.withLock { + withContext(Dispatchers.IO) { + pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight) + } + } + mutableStateFlow.value = State.Loaded(bitmap) + } + } + + fun close() { + loadJob?.cancel() + when (val loadingState = stateFlow.value) { + is State.Loading -> return + is State.Loaded -> { + loadingState.bitmap.recycle() + mutableStateFlow.value = State.Loading( + width = renderWidth, + height = renderHeight + ) + } + } + } + + private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap { + fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap { + val bitmap = Bitmap.createBitmap( + bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, 0f, 0f, null) + return bitmap + } + return openPage(index).use { page -> + createBitmap(bitmapWidth, bitmapHeight).apply { + page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + } + } + } +} + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt new file mode 100644 index 0000000000..8f6c507eb5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfRendererManager.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.pdf + +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class PdfRendererManager( + private val parcelFileDescriptor: ParcelFileDescriptor, + private val width: Int, + private val coroutineScope: CoroutineScope, +) { + + private val mutex = Mutex() + private var pdfRenderer: PdfRenderer? = null + private val mutablePdfPages = MutableStateFlow<List<PdfPage>>(emptyList()) + val pdfPages: StateFlow<List<PdfPage>> = mutablePdfPages + + fun open() { + coroutineScope.launch { + mutex.withLock { + withContext(Dispatchers.IO) { + pdfRenderer = PdfRenderer(parcelFileDescriptor).apply { + // Preload just 3 pages so we can render faster + val firstPages = loadPages(from = 0, to = 3) + mutablePdfPages.value = firstPages + val nextPages = loadPages(from = 3, to = pageCount) + mutablePdfPages.value = firstPages + nextPages + } + } + } + } + } + + fun close() { + coroutineScope.launch { + mutex.withLock { + mutablePdfPages.value.forEach { pdfPage -> + pdfPage.close() + } + pdfRenderer?.close() + parcelFileDescriptor.close() + } + } + } + + private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> { + return (from until minOf(to, pageCount)).map { pageIndex -> + PdfPage(width, pageIndex, mutex, this, coroutineScope) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt new file mode 100644 index 0000000000..41a111d97d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewer.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.pdf + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.text.toDp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import me.saket.telephoto.zoomable.zoomable + +@Composable +fun PdfViewer( + pdfViewerState: PdfViewerState, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier.zoomable(pdfViewerState.zoomableState), + contentAlignment = Alignment.Center + ) { + val maxWidthInPx = maxWidth.roundToPx() + DisposableEffect(pdfViewerState) { + pdfViewerState.openForWidth(maxWidthInPx) + onDispose { + pdfViewerState.close() + } + } + val pdfPages = pdfViewerState.getPages() + PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState) + } +} + +@Composable +private fun PdfPagesView( + pdfPages: ImmutableList<PdfPage>, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) + + ) { + items(pdfPages.size) { index -> + val pdfPage = pdfPages[index] + PdfPageView(pdfPage) + } + } +} + +@Composable +private fun PdfPageView( + pdfPage: PdfPage, + modifier: Modifier = Modifier, +) { + val pdfPageState by pdfPage.stateFlow.collectAsState() + DisposableEffect(pdfPage) { + pdfPage.load() + onDispose { + pdfPage.close() + } + } + when (val state = pdfPageState) { + is PdfPage.State.Loaded -> { + Image( + bitmap = state.bitmap.asImageBitmap(), + contentDescription = "Page ${pdfPage.pageIndex}", + contentScale = ContentScale.FillWidth, + modifier = modifier.fillMaxWidth() + ) + } + is PdfPage.State.Loading -> { + Box( + modifier = modifier + .fillMaxWidth() + .height(state.height.toDp()) + .background(color = Color.White) + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt new file mode 100644 index 0000000000..f64374d478 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/pdf/PdfViewerState.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.local.pdf + +import android.content.Context +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import me.saket.telephoto.zoomable.ZoomableState +import me.saket.telephoto.zoomable.rememberZoomableState + +@Stable +class PdfViewerState( + private val model: Any?, + private val coroutineScope: CoroutineScope, + private val context: Context, + val zoomableState: ZoomableState, + val lazyListState: LazyListState, +) { + + var isLoaded by mutableStateOf(false) + private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null) + + @Composable + fun getPages(): List<PdfPage>{ + return pdfRendererManager?.run { + pdfPages.collectAsState().value + }?: emptyList() + } + + fun openForWidth(maxWidth: Int) { + ParcelFileDescriptorFactory(context).create(model) + .onSuccess { + pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply { + open() + } + isLoaded = true + } + } + + fun close() { + pdfRendererManager?.close() + isLoaded = false + } +} + +@Composable +fun rememberPdfViewerState( + model: Any?, + zoomableState: ZoomableState = rememberZoomableState(), + lazyListState: LazyListState = rememberLazyListState(), + context: Context = LocalContext.current, + coroutineScope: CoroutineScope = rememberCoroutineScope(), +): PdfViewerState { + return remember(model) { + PdfViewerState( + model = model, + coroutineScope = coroutineScope, + context = context, + zoomableState = zoomableState, + lazyListState = lazyListState + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt new file mode 100644 index 0000000000..b680ee58c9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.viewer + +sealed interface MediaViewerEvents { + object SaveOnDisk: MediaViewerEvents + object Share: MediaViewerEvents + object OpenWith: MediaViewerEvents + object RetryLoading : MediaViewerEvents + object ClearLoadingError : MediaViewerEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt new file mode 100644 index 0000000000..78ecc2821b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerNode.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.viewer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.theme.ForcedDarkElementTheme +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.media.MediaSource + +@ContributesNode(RoomScope::class) +class MediaViewerNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: MediaViewerPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : NodeInputs + + private val inputs: Inputs = inputs() + + private val presenter = presenterFactory.create(inputs) + + @Composable + override fun View(modifier: Modifier) { + ForcedDarkElementTheme { + val state = presenter.present() + MediaViewerView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt new file mode 100644 index 0000000000..7cc73ef32e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.viewer + +import android.content.ActivityNotFoundException +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaActions +import io.element.android.features.messages.impl.media.local.LocalMediaFactory +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import io.element.android.libraries.androidutils.R as UtilsR + +class MediaViewerPresenter @AssistedInject constructor( + @Assisted private val inputs: MediaViewerNode.Inputs, + private val localMediaFactory: LocalMediaFactory, + private val mediaLoader: MatrixMediaLoader, + private val localMediaActions: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter<MediaViewerState> { + + @AssistedFactory + interface Factory { + fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter + } + + @Composable + override fun present(): MediaViewerState { + val coroutineScope = rememberCoroutineScope() + var loadMediaTrigger by remember { mutableStateOf(0) } + val mediaFile: MutableState<MediaFile?> = remember { + mutableStateOf(null) + } + val localMedia: MutableState<Async<LocalMedia>> = remember { + mutableStateOf(Async.Uninitialized) + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() + DisposableEffect(loadMediaTrigger) { + coroutineScope.downloadMedia(mediaFile, localMedia) + onDispose { + mediaFile.value?.close() + } + } + + fun handleEvents(mediaViewerEvents: MediaViewerEvents) { + when (mediaViewerEvents) { + MediaViewerEvents.RetryLoading -> loadMediaTrigger++ + MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized + MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) + } + } + + return MediaViewerState( + mediaInfo = inputs.mediaInfo, + thumbnailSource = inputs.thumbnailSource, + downloadedMedia = localMedia.value, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch { + localMedia.value = Async.Loading() + mediaLoader.downloadMediaFile( + source = inputs.mediaSource, + mimeType = inputs.mediaInfo.mimeType, + body = inputs.mediaInfo.name + ) + .onSuccess { + mediaFile.value = it + }.mapCatching { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = inputs.mediaInfo + ) + }.onSuccess { + localMedia.value = Async.Success(it) + }.onFailure { + localMedia.value = Async.Failure(it) + } + } + + private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch { + if (localMedia is Async.Success) { + localMediaActions.saveOnDisk(localMedia.data) + .onSuccess { + val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android) + snackbarDispatcher.post(snackbarMessage) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit + } + + private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch { + if (localMedia is Async.Success) { + localMediaActions.share(localMedia.data) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit + } + + private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch { + if (localMedia is Async.Success) { + localMediaActions.open(localMedia.data) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } else Unit + } + + private fun mediaActionsError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + UtilsR.string.error_no_compatible_app_found + } else { + CommonStrings.error_unknown + } + } +} + + + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt new file mode 100644 index 0000000000..18375746c5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.viewer + +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.media.MediaSource + +data class MediaViewerState( + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + val downloadedMedia: Async<LocalMedia>, + val snackbarMessage: SnackbarMessage?, + val eventSink: (MediaViewerEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt new file mode 100644 index 0000000000..820a34d8d4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.media.viewer + +import android.net.Uri +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.local.aPdfInfo +import io.element.android.features.messages.impl.media.local.aVideoInfo +import io.element.android.features.messages.impl.media.local.anAudioInfo +import io.element.android.features.messages.impl.media.local.anImageInfo +import io.element.android.libraries.architecture.Async + +open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { + override val values: Sequence<MediaViewerState> + get() = sequenceOf( + aMediaViewerState(), + aMediaViewerState(Async.Loading()), + aMediaViewerState(Async.Failure(IllegalStateException())), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, anImageInfo()) + ), + anImageInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aVideoInfo()) + ), + aVideoInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aPdfInfo()) + ), + aPdfInfo(), + ), + aMediaViewerState( + Async.Loading(), + aFileInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, aFileInfo()) + ), + aFileInfo(), + ), + aMediaViewerState( + Async.Loading(), + anAudioInfo(), + ), + aMediaViewerState( + Async.Success( + LocalMedia(Uri.EMPTY, anAudioInfo()) + ), + anAudioInfo(), + ), + ) +} + +fun aMediaViewerState( + downloadedMedia: Async<LocalMedia> = Async.Uninitialized, + mediaInfo: MediaInfo = anImageInfo(), +) = MediaViewerState( + mediaInfo = mediaInfo, + thumbnailSource = null, + downloadedMedia = downloadedMedia, + snackbarMessage = null +) {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt new file mode 100644 index 0000000000..5964c2ced7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.messages.impl.media.viewer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaView +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.delay + +@Composable +fun MediaViewerView( + state: MediaViewerState, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + + fun onRetry() { + state.eventSink(MediaViewerEvents.RetryLoading) + } + + fun onDismissError() { + state.eventSink(MediaViewerEvents.ClearLoadingError) + } + + val localMediaViewState = rememberLocalMediaViewState() + val showThumbnail = !localMediaViewState.isReady + val showProgress = rememberShowProgress(state.downloadedMedia) + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + Scaffold( + modifier, + topBar = { + MediaViewerTopBar( + actionsEnabled = state.downloadedMedia is Async.Success, + onBackPressed = onBackPressed, + eventSink = state.eventSink + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) + } + }, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + ) { + if (showProgress) { + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .height(2.dp) + ) + } else { + Spacer(Modifier.height(2.dp)) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (state.downloadedMedia is Async.Failure) { + ErrorView( + errorMessage = stringResource(id = CommonStrings.error_unknown), + onRetry = ::onRetry, + onDismiss = ::onDismissError + ) + } + LocalMediaView( + localMediaViewState = localMediaViewState, + localMedia = state.downloadedMedia.dataOrNull(), + mediaInfo = state.mediaInfo, + ) + ThumbnailView( + mediaInfo = state.mediaInfo, + thumbnailSource = state.thumbnailSource, + showThumbnail = showThumbnail, + ) + } + } + } +} + +@Composable +private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean { + var showProgress by remember { + mutableStateOf(false) + } + if (LocalInspectionMode.current) { + showProgress = downloadedMedia.isLoading() + } else { + // Trick to avoid showing progress indicator if the media is already on disk. + // When sdk will expose download progress we'll be able to remove this. + LaunchedEffect(downloadedMedia) { + showProgress = false + delay(100) + if (downloadedMedia.isLoading()) { + showProgress = true + } + } + } + return showProgress +} + +@Composable +private fun MediaViewerTopBar( + actionsEnabled: Boolean, + onBackPressed: () -> Unit, + eventSink: (MediaViewerEvents) -> Unit, +) { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.OpenWith) + }, + ) { + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = stringResource(id = CommonStrings.action_open_with)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.SaveOnDisk) + }, + ) { + Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = CommonStrings.action_save)) + } + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.Share) + }, + ) { + Icon(imageVector = Icons.Default.Share, contentDescription = stringResource(id = CommonStrings.action_share)) + } + } + ) +} + +@Composable +private fun ThumbnailView( + thumbnailSource: MediaSource?, + showThumbnail: Boolean, + mediaInfo: MediaInfo, +) { + AnimatedVisibility( + visible = showThumbnail, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val mediaRequestData = MediaRequestData( + source = thumbnailSource, + kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType) + ) + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = mediaRequestData, + alpha = 0.8f, + contentScale = ContentScale.Fit, + contentDescription = null, + ) + } + } +} + +@Composable +private fun ErrorView( + errorMessage: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + RetryDialog( + modifier = modifier, + content = errorMessage, + onRetry = onRetry, + onDismiss = onDismiss + ) +} + +@Preview +@Composable +fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: MediaViewerState) { + MediaViewerView( + state = state, + onBackPressed = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt new file mode 100644 index 0000000000..c8324ec677 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ListItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Collections +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AttachmentsBottomSheet( + state: MessageComposerState, + onSendLocationClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val localView = LocalView.current + var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) } + + BackHandler(enabled = isVisible) { + isVisible = false + } + + LaunchedEffect(state.showAttachmentSourcePicker) { + if (state.showAttachmentSourcePicker) { + // We need to use this instead of `LocalFocusManager.clearFocus()` to hide the keyboard when focus is on an Android View + localView.hideKeyboard() + isVisible = true + } else { + isVisible = false + } + } + // Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden + LaunchedEffect(isVisible) { + if (!isVisible) { + state.eventSink(MessageComposerEvents.DismissAttachmentMenu) + } + } + + if (isVisible) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = { isVisible = false } + ) { + AttachmentSourcePickerMenu( + eventSink = state.eventSink, + onSendLocationClicked = onSendLocationClicked, + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun AttachmentSourcePickerMenu( + eventSink: (MessageComposerEvents) -> Unit, + onSendLocationClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier.padding(bottom = 32.dp) +// .navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 + ) { + ListItem( + modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, + icon = { Icon(Icons.Default.Collections, null) }, + text = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, + ) + ListItem( + modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, + icon = { Icon(Icons.Default.AttachFile, null) }, + text = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, + ) + ListItem( + modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) }, + icon = { Icon(Icons.Default.PhotoCamera, null) }, + text = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) }, + ) + ListItem( + modifier = Modifier.clickable { eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) }, + icon = { Icon(Icons.Default.Videocam, null) }, + text = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, + ) + ListItem( + modifier = Modifier.clickable { + eventSink(MessageComposerEvents.PickAttachmentSource.Location) + onSendLocationClicked() + }, + icon = { Icon(Icons.Default.LocationOn, null) }, + text = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, + ) + } +} + +@DayNightPreviews +@Composable +internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { + AttachmentSourcePickerMenu( + eventSink = {}, + onSendLocationClicked = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt new file mode 100644 index 0000000000..73481cd617 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.textcomposer.MessageComposerMode +import javax.inject.Inject + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class MessageComposerContextImpl @Inject constructor() : MessageComposerContext { + override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal("")) + internal set +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt new file mode 100644 index 0000000000..46e57e92de --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.textcomposer.MessageComposerMode + +@Immutable +sealed interface MessageComposerEvents { + object ToggleFullScreenState : MessageComposerEvents + data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents + data class SendMessage(val message: String) : MessageComposerEvents + object CloseSpecialMode : MessageComposerEvents + data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents + data class UpdateText(val text: String) : MessageComposerEvents + object AddAttachment : MessageComposerEvents + object DismissAttachmentMenu : MessageComposerEvents + sealed interface PickAttachmentSource : MessageComposerEvents { + object FromGallery : PickAttachmentSource + object FromFiles : PickAttachmentSource + object PhotoFromCamera : PickAttachmentSource + object VideoFromCamera : PickAttachmentSource + object Location : PickAttachmentSource + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt new file mode 100644 index 0000000000..020236e890 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError +import io.element.android.features.messages.impl.media.local.LocalMediaFactory +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes + +@SingleIn(RoomScope::class) +class MessageComposerPresenter @Inject constructor( + private val appCoroutineScope: CoroutineScope, + private val room: MatrixRoom, + private val mediaPickerProvider: PickerProvider, + private val featureFlagService: FeatureFlagService, + private val localMediaFactory: LocalMediaFactory, + private val mediaSender: MediaSender, + private val snackbarDispatcher: SnackbarDispatcher, + private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContextImpl, +) : Presenter<MessageComposerState> { + + @SuppressLint("UnsafeOptInUsageError") + @Composable + override fun present(): MessageComposerState { + val localCoroutineScope = rememberCoroutineScope() + + val attachmentsState = remember { + mutableStateOf<AttachmentsState>(AttachmentsState.None) + } + + val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> + handlePickedMedia(attachmentsState, uri, mimeType) + } + val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri -> + handlePickedMedia(attachmentsState, uri, compressIfPossible = false) + } + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> + handlePickedMedia(attachmentsState, uri, MimeTypes.IMAGE_JPEG) + } + val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> + handlePickedMedia(attachmentsState, uri, MimeTypes.VIDEO_MP4) + } + val isFullScreen = rememberSaveable { + mutableStateOf(false) + } + val hasFocus = remember { + mutableStateOf(false) + } + val text: MutableState<String> = rememberSaveable { + mutableStateOf("") + } + + var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } + + LaunchedEffect(messageComposerContext.composerMode) { + when (val modeValue = messageComposerContext.composerMode) { + is MessageComposerMode.Edit -> text.value = modeValue.defaultContent + else -> Unit + } + } + + LaunchedEffect(attachmentsState.value) { + when (val attachmentStateValue = attachmentsState.value) { + is AttachmentsState.Sending.Processing -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState) + else -> Unit + } + } + + fun handleEvents(event: MessageComposerEvents) { + when (event) { + MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value + + is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus + + is MessageComposerEvents.UpdateText -> text.value = event.text + MessageComposerEvents.CloseSpecialMode -> { + text.value = "" + messageComposerContext.composerMode = MessageComposerMode.Normal("") + } + + is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( + text = event.message, + updateComposerMode = { messageComposerContext.composerMode = it }, + textState = text + ) + is MessageComposerEvents.SetMode -> { + messageComposerContext.composerMode = event.composerMode + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + isLocation = false, + ) + ) + } + MessageComposerEvents.AddAttachment -> localCoroutineScope.launchIfMediaPickerEnabled { + showAttachmentSourcePicker = true + } + MessageComposerEvents.DismissAttachmentMenu -> showAttachmentSourcePicker = false + MessageComposerEvents.PickAttachmentSource.FromGallery -> localCoroutineScope.launchIfMediaPickerEnabled { + showAttachmentSourcePicker = false + galleryMediaPicker.launch() + } + MessageComposerEvents.PickAttachmentSource.FromFiles -> localCoroutineScope.launchIfMediaPickerEnabled { + showAttachmentSourcePicker = false + filesPicker.launch() + } + MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { + showAttachmentSourcePicker = false + cameraPhotoPicker.launch() + } + MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launchIfMediaPickerEnabled { + showAttachmentSourcePicker = false + cameraVideoPicker.launch() + } + MessageComposerEvents.PickAttachmentSource.Location -> { + showAttachmentSourcePicker = false + // Navigation to the location picker screen is done at the view layer + } + } + } + + return MessageComposerState( + text = text.value, + isFullScreen = isFullScreen.value, + hasFocus = hasFocus.value, + mode = messageComposerContext.composerMode, + showAttachmentSourcePicker = showAttachmentSourcePicker, + attachmentsState = attachmentsState.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.launchIfMediaPickerEnabled(action: suspend () -> Unit) = launch { + if (featureFlagService.isFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow)) { + action() + } + } + + private fun CoroutineScope.sendMessage( + text: String, + updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, + textState: MutableState<String> + ) = launch { + val capturedMode = messageComposerContext.composerMode + // Reset composer right away + textState.value = "" + updateComposerMode(MessageComposerMode.Normal("")) + when (capturedMode) { + is MessageComposerMode.Normal -> room.sendMessage(text) + is MessageComposerMode.Edit -> { + val eventId = capturedMode.eventId + val transactionId = capturedMode.transactionId + room.editMessage(eventId, transactionId, text) + } + + is MessageComposerMode.Quote -> TODO() + is MessageComposerMode.Reply -> room.replyMessage( + capturedMode.eventId, + text + ) + } + } + + private fun CoroutineScope.sendAttachment( + attachment: Attachment, + attachmentState: MutableState<AttachmentsState>, + ) = launch { + when (attachment) { + is Attachment.Media -> { + sendMedia( + uri = attachment.localMedia.uri, + mimeType = attachment.localMedia.info.mimeType, + attachmentState = attachmentState + ) + } + } + } + + @UnstableApi + private fun handlePickedMedia( + attachmentsState: MutableState<AttachmentsState>, + uri: Uri?, + mimeType: String? = null, + compressIfPossible: Boolean = true, + ) { + if (uri == null) { + attachmentsState.value = AttachmentsState.None + return + } + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = mimeType, + name = null, + formattedFileSize = null + ) + val mediaAttachment = Attachment.Media(localMedia, compressIfPossible) + val isPreviewable = when { + MimeTypes.isImage(localMedia.info.mimeType) -> true + MimeTypes.isVideo(localMedia.info.mimeType) -> true + MimeTypes.isAudio(localMedia.info.mimeType) -> true + else -> false + } + attachmentsState.value = if (isPreviewable) { + AttachmentsState.Previewing(persistentListOf(mediaAttachment)) + } else { + AttachmentsState.Sending.Processing(persistentListOf(mediaAttachment)) + } + } + + private suspend fun sendMedia( + uri: Uri, + mimeType: String, + attachmentState: MutableState<AttachmentsState>, + ) { + val progressCallback = object : ProgressCallback { + override fun onProgress(current: Long, total: Long) { + attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat()) + } + } + mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback) + .onSuccess { + attachmentState.value = AttachmentsState.None + }.onFailure { + val snackbarMessage = SnackbarMessage(sendAttachmentError(it)) + snackbarDispatcher.post(snackbarMessage) + attachmentState.value = AttachmentsState.None + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt new file mode 100644 index 0000000000..28ec14ffeb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.textcomposer.MessageComposerMode +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class MessageComposerState( + val text: String?, + val isFullScreen: Boolean, + val hasFocus: Boolean, + val mode: MessageComposerMode, + val showAttachmentSourcePicker: Boolean, + val attachmentsState: AttachmentsState, + val eventSink: (MessageComposerEvents) -> Unit +) { + val isSendButtonVisible: Boolean = text.isNullOrEmpty().not() +} + +@Immutable +sealed interface AttachmentsState { + object None : AttachmentsState + data class Previewing(val attachments: ImmutableList<Attachment>) : AttachmentsState + sealed interface Sending : AttachmentsState { + data class Processing(val attachments: ImmutableList<Attachment>) : Sending + data class Uploading(val progress: Float) : Sending + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt new file mode 100644 index 0000000000..1934154824 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.textcomposer.MessageComposerMode + +open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> { + override val values: Sequence<MessageComposerState> + get() = sequenceOf( + aMessageComposerState(), + ) +} + +fun aMessageComposerState() = MessageComposerState( + text = "", + isFullScreen = false, + hasFocus = false, + mode = MessageComposerMode.Normal(content = ""), + showAttachmentSourcePicker = false, + attachmentsState = AttachmentsState.None, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt new file mode 100644 index 0000000000..5c6c07d80e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.textcomposer.TextComposer + +@Composable +fun MessageComposerView( + state: MessageComposerState, + onSendLocationClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + fun onFullscreenToggle() { + state.eventSink(MessageComposerEvents.ToggleFullScreenState) + } + + fun sendMessage(message: String) { + state.eventSink(MessageComposerEvents.SendMessage(message)) + } + + fun onAddAttachment() { + state.eventSink(MessageComposerEvents.AddAttachment) + } + + fun onCloseSpecialMode() { + state.eventSink(MessageComposerEvents.CloseSpecialMode) + } + + fun onComposerTextChange(text: String) { + state.eventSink(MessageComposerEvents.UpdateText(text)) + } + + fun onFocusChanged(hasFocus: Boolean) { + state.eventSink(MessageComposerEvents.FocusChanged(hasFocus)) + } + + Box { + AttachmentsBottomSheet( + state = state, + onSendLocationClicked = onSendLocationClicked, + ) + + TextComposer( + onSendMessage = ::sendMessage, + composerMode = state.mode, + onResetComposerMode = ::onCloseSpecialMode, + onComposerTextChange = ::onComposerTextChange, + onAddAttachment = ::onAddAttachment, + onFocusChanged = ::onFocusChanged, + composerCanSendMessage = state.isSendButtonVisible, + composerText = state.text, + modifier = modifier + ) + } +} + +@Preview +@Composable +internal fun MessageComposerViewLightPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: MessageComposerState) { + MessageComposerView( + state = state, + onSendLocationClicked = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt new file mode 100644 index 0000000000..ed5ee029e7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +sealed interface ReportMessageEvents { + data class UpdateReason(val reason: String) : ReportMessageEvents + object ToggleBlockUser : ReportMessageEvents + object Report : ReportMessageEvents + object ClearError : ReportMessageEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt new file mode 100644 index 0000000000..1be4571161 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesNode(RoomScope::class) +class ReportMessageNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + presenterFactory: ReportMessagePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val eventId: EventId, + val senderId: UserId, + ) : NodeInputs + + private val inputs = inputs<Inputs>() + + private val presenter = presenterFactory.create( + ReportMessagePresenter.Inputs(inputs.eventId, inputs.senderId) + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ReportMessageView( + state = state, + onBackClicked = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt new file mode 100644 index 0000000000..0ce4856ffa --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ReportMessagePresenter @AssistedInject constructor( + private val room: MatrixRoom, + @Assisted private val inputs: Inputs, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter<ReportMessageState> { + + data class Inputs( + val eventId: EventId, + val senderId: UserId, + ) + + @AssistedFactory + interface Factory { + fun create(inputs: Inputs): ReportMessagePresenter + } + + @Composable + override fun present(): ReportMessageState { + val coroutineScope = rememberCoroutineScope() + var reason by rememberSaveable { mutableStateOf("") } + var blockUser by rememberSaveable { mutableStateOf(false) } + var result: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } + + fun handleEvents(event: ReportMessageEvents) { + when (event) { + is ReportMessageEvents.UpdateReason -> reason = event.reason + ReportMessageEvents.ToggleBlockUser -> blockUser = !blockUser + ReportMessageEvents.Report -> coroutineScope.report(inputs.eventId, inputs.senderId, reason, blockUser, result) + ReportMessageEvents.ClearError -> result.value = Async.Uninitialized + } + } + + return ReportMessageState( + reason = reason, + blockUser = blockUser, + result = result.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.report( + eventId: EventId, + userId: UserId, + reason: String, + blockUser: Boolean, + result: MutableState<Async<Unit>>, + ) = launch { + result.runUpdatingState { + val userIdToBlock = userId.takeIf { blockUser } + room.reportContent(eventId, reason, userIdToBlock) + .onSuccess { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_report_submitted)) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt new file mode 100644 index 0000000000..809668c88f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +import io.element.android.libraries.architecture.Async + +data class ReportMessageState( + val reason: String, + val blockUser: Boolean, + val result: Async<Unit>, + val eventSink: (ReportMessageEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt new file mode 100644 index 0000000000..89e6d7a220 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class ReportMessageStateProvider : PreviewParameterProvider<ReportMessageState> { + override val values: Sequence<ReportMessageState> + get() = sequenceOf( + aReportMessageState(), + aReportMessageState(reason = "This user is making the chat very toxic."), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Loading()), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Failure(Throwable())), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = Async.Success(Unit)), + // Add other states here + ) +} + +fun aReportMessageState( + reason: String = "", + blockUser: Boolean = false, + result: Async<Unit> = Async.Uninitialized, +) = ReportMessageState( + reason = reason, + blockUser = blockUser, + result = result, + eventSink = {} +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt new file mode 100644 index 0000000000..d9851c82a9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.report + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ReportMessageView( + state: ReportMessageState, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isSending = state.result is Async.Loading + when (state.result) { + is Async.Success -> { + LaunchedEffect(state.result) { + onBackClicked() + } + return + } + is Async.Failure -> { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + onDismiss = { state.eventSink(ReportMessageEvents.ClearError) } + ) + } + else -> Unit + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource(CommonStrings.action_report_content), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { + BackButton(onClick = onBackClicked) + } + ) + }, + modifier = modifier + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + + OutlinedTextField( + value = state.reason, + onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) }, + placeholder = { Text(stringResource(CommonStrings.report_content_hint)) }, + enabled = !isSending, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 90.dp) + ) + Text( + text = stringResource(CommonStrings.report_content_explanation), + style = ElementTheme.typography.fontBodySmRegular, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Start, + modifier = Modifier.padding(top = 4.dp, bottom = 24.dp, start = 16.dp, end = 16.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(CommonStrings.screen_report_content_block_user), + style = ElementTheme.typography.fontBodyLgRegular, + ) + Text( + text = stringResource(CommonStrings.screen_report_content_block_user_hint), + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.secondary, + ) + } + Switch( + enabled = !isSending, + checked = state.blockUser, + onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + ButtonWithProgress( + text = stringResource(CommonStrings.action_send), + enabled = state.reason.isNotBlank() && !isSending, + showProgress = isSending, + onClick = { + focusManager.clearFocus(force = true) + state.eventSink(ReportMessageEvents.Report) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) + } + } +} + +@Preview +@Composable +fun ReportMessageViewLightPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ReportMessageViewDarkPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ReportMessageState) { + ReportMessageView( + onBackClicked = {}, + state = state, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt new file mode 100644 index 0000000000..2bfed45470 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface TimelineEvents { + object LoadMore : TimelineEvents + data class SetHighlightedEvent(val eventId: EventId?) : TimelineEvents + data class OnScrollFinished(val firstIndex: Int) : TimelineEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt new file mode 100644 index 0000000000..cb8b536be1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.ui.room.canSendMessageAsState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +private const val backPaginationEventLimit = 20 +private const val backPaginationPageSize = 50 + +class TimelinePresenter @Inject constructor( + private val timelineItemsFactory: TimelineItemsFactory, + private val room: MatrixRoom, + private val dispatchers: CoroutineDispatchers, + private val appScope: CoroutineScope, +) : Presenter<TimelineState> { + + private val timeline = room.timeline + + @Composable + override fun present(): TimelineState { + val localScope = rememberCoroutineScope() + val highlightedEventId: MutableState<EventId?> = rememberSaveable { + mutableStateOf(null) + } + + val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) } + val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } + + val timelineItems by timelineItemsFactory.collectItemsAsState() + val paginationState by timeline.paginationState.collectAsState() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) + + val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } + val hasNewItems = remember { mutableStateOf(false) } + + fun handleEvents(event: TimelineEvents) { + when (event) { + TimelineEvents.LoadMore -> localScope.paginateBackwards() + is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId + is TimelineEvents.OnScrollFinished -> { + if (event.firstIndex == 0) { + hasNewItems.value = false + } + appScope.sendReadReceiptIfNeeded( + firstVisibleIndex = event.firstIndex, + timelineItems = timelineItems, + lastReadReceiptIndex = lastReadReceiptIndex, + lastReadReceiptId = lastReadReceiptId + ) + } + } + } + + LaunchedEffect(timelineItems.size) { + computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems) + } + + LaunchedEffect(Unit) { + timeline + .timelineItems + .onEach(timelineItemsFactory::replaceWith) + .onEach { timelineItems -> + if (timelineItems.isEmpty()) { + paginateBackwards() + } + } + .launchIn(this) + } + + return TimelineState( + highlightedEventId = highlightedEventId.value, + canReply = userHasPermissionToSendMessage, + paginationState = paginationState, + timelineItems = timelineItems, + hasNewItems = hasNewItems.value, + eventSink = ::handleEvents + ) + } + + /** + * This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes. + * Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items. + * The state never goes back to false from this method, but need to be reset from somewhere else. + */ + private suspend fun computeHasNewItems( + timelineItems: ImmutableList<TimelineItem>, + prevMostRecentItemId: MutableState<String?>, + hasNewItemsState: MutableState<Boolean> + ) = withContext(dispatchers.computation) { + val newMostRecentItem = timelineItems.firstOrNull() + val prevMostRecentItemIdValue = prevMostRecentItemId.value + val newMostRecentItemId = newMostRecentItem?.identifier() + val hasNewItems = prevMostRecentItemIdValue != null && + newMostRecentItem is TimelineItem.Event && + newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && + newMostRecentItemId != prevMostRecentItemIdValue + if (hasNewItems) { + hasNewItemsState.value = true + } + prevMostRecentItemId.value = newMostRecentItemId + } + + private fun CoroutineScope.sendReadReceiptIfNeeded( + firstVisibleIndex: Int, + timelineItems: ImmutableList<TimelineItem>, + lastReadReceiptIndex: MutableState<Int>, + lastReadReceiptId: MutableState<EventId?>, + ) = launch(dispatchers.computation) { + // Get last valid EventId seen by the user, as the first index might refer to a Virtual item + val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) + if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) { + lastReadReceiptIndex.value = firstVisibleIndex + lastReadReceiptId.value = eventId + timeline.sendReadReceipt(eventId) + } + } + + private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? { + for (item in items.subList(index, items.count())) { + if (item is TimelineItem.Event) { + return item.eventId + } + } + return null + } + + private fun CoroutineScope.paginateBackwards() = launch { + timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt new file mode 100644 index 0000000000..ab5874d39c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class TimelineState( + val timelineItems: ImmutableList<TimelineItem>, + val highlightedEventId: EventId?, + val canReply: Boolean, + val paginationState: MatrixTimeline.PaginationState, + val hasNewItems: Boolean, + val eventSink: (TimelineEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt new file mode 100644 index 0000000000..e939b9ff68 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList +import java.util.UUID +import kotlin.random.Random + +fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( + timelineItems = timelineItems, + paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), + highlightedEventId = null, + canReply = true, + hasNewItems = false, + eventSink = {}, +) + +internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> { + return persistentListOf( + // 3 items (First Middle Last) with isMine = false + aTimelineItemEvent( + isMine = false, + content = content, + groupPosition = TimelineItemGroupPosition.Last + ), + aTimelineItemEvent( + isMine = false, + content = content, + groupPosition = TimelineItemGroupPosition.Middle, + sendState = LocalEventSendState.SendingFailed("Message failed to send"), + ), + aTimelineItemEvent( + isMine = false, + content = content, + groupPosition = TimelineItemGroupPosition.First + ), + // A state event on top of it + aTimelineItemEvent( + isMine = false, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ), + // 3 items (First Middle Last) with isMine = true + aTimelineItemEvent( + isMine = true, + content = content, + groupPosition = TimelineItemGroupPosition.Last + ), + aTimelineItemEvent( + isMine = true, + content = content, + groupPosition = TimelineItemGroupPosition.Middle, + sendState = LocalEventSendState.SendingFailed("Message failed to send"), + ), + aTimelineItemEvent( + isMine = true, + content = content, + groupPosition = TimelineItemGroupPosition.First + ), + // A grouped event on top of it + aGroupedEvents(), + // A day separator + aTimelineItemDaySeparator(), + ) +} + +fun aTimelineItemDaySeparator(): TimelineItem.Virtual { + return TimelineItem.Virtual(UUID.randomUUID().toString(), aTimelineItemDaySeparatorModel("Today")) +} + +internal fun aTimelineItemEvent( + eventId: EventId = EventId("\$" + Random.nextInt().toString()), + transactionId: TransactionId? = null, + isMine: Boolean = false, + content: TimelineItemEventContent = aTimelineItemTextContent(), + groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, + sendState: LocalEventSendState = LocalEventSendState.Sent(eventId), + inReplyTo: InReplyTo? = null, + debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), + timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), +): TimelineItem.Event { + return TimelineItem.Event( + id = UUID.randomUUID().toString(), + eventId = eventId, + transactionId = transactionId, + senderId = UserId("@senderId:domain"), + senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender), + content = content, + reactionsState = timelineItemReactions, + sentTime = "12:34", + isMine = isMine, + senderDisplayName = "Sender", + groupPosition = groupPosition, + localSendState = sendState, + inReplyTo = inReplyTo, + debugInfo = debugInfo, + origin = null + ) +} + +fun aTimelineItemReactions( + count: Int = 1, + isHighlighted: Boolean = false, +): TimelineItemReactions { + val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️") + return TimelineItemReactions( + reactions = buildList { + repeat(count) { index -> + val key = emojis[index % emojis.size] + add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted)) + } + }.toPersistentList() + ) +} + +internal fun aTimelineItemDebugInfo( + model: String = "Rust(Model())", + originalJson: String? = null, + latestEditedJson: String? = null, +) = TimelineItemDebugInfo( + model, originalJson, latestEditedJson +) + +fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents { + val event = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ) + return TimelineItem.GroupedEvents( + id = id.toString(), + events = listOf( + event, + event, + ).toImmutableList() + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt new file mode 100644 index 0000000000..d7820e707b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalAnimationApi::class) + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.components.TimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemStateEventRow +import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow +import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.theme.ElementTheme +import kotlinx.coroutines.launch + +@Composable +fun TimelineView( + state: TimelineState, + onUserDataClicked: (UserId) -> Unit, + onMessageClicked: (TimelineItem.Event) -> Unit, + onMessageLongClicked: (TimelineItem.Event) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, + onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + fun onReachedLoadMore() { + state.eventSink(TimelineEvents.LoadMore) + } + + fun onScrollFinishedAt(firstVisibleIndex: Int) { + state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) + } + + val lazyListState = rememberLazyListState() + + fun inReplyToClicked(eventId: EventId) { + // TODO implement this logic once we have support to 'jump to event X' in sliding sync + } + + + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + reverseLayout = true, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + highlightedItem = state.highlightedEventId?.value, + canReply = state.canReply, + onClick = onMessageClicked, + onLongClick = onMessageLongClicked, + onUserDataClick = onUserDataClicked, + inReplyToClick = ::inReplyToClicked, + onReactionClick = onReactionClicked, + onMoreReactionsClick = onMoreReactionsClicked, + onTimestampClicked = onTimestampClicked, + onSwipeToReply = onSwipeToReply, + ) + } + if (state.paginationState.hasMoreToLoadBackwards) { + // Do not use key parameter to avoid wrong positioning + item(contentType = "TimelineLoadingMoreIndicator") { + TimelineLoadingMoreIndicator() + LaunchedEffect(Unit) { + onReachedLoadMore() + } + } + } + } + + TimelineScrollHelper( + lazyListState = lazyListState, + hasNewItems = state.hasNewItems, + onScrollFinishedAt = ::onScrollFinishedAt + ) + } +} + +@Composable +fun TimelineItemRow( + timelineItem: TimelineItem, + highlightedItem: String?, + canReply: Boolean, + onUserDataClick: (UserId) -> Unit, + onClick: (TimelineItem.Event) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier +) { + when (timelineItem) { + is TimelineItem.Virtual -> { + TimelineItemVirtualRow( + virtual = timelineItem, + modifier = modifier, + ) + } + is TimelineItem.Event -> { + if (timelineItem.content is TimelineItemStateContent) { + TimelineItemStateEventRow( + event = timelineItem, + isHighlighted = highlightedItem == timelineItem.identifier(), + onClick = { onClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) }, + modifier = modifier, + ) + } else { + TimelineItemEventRow( + event = timelineItem, + isHighlighted = highlightedItem == timelineItem.identifier(), + canReply = canReply, + onClick = { onClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) }, + onUserDataClick = onUserDataClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onMoreReactionsClick = onMoreReactionsClick, + onTimestampClicked = onTimestampClicked, + onSwipeToReply = { onSwipeToReply(timelineItem) }, + modifier = modifier, + ) + } + } + is TimelineItem.GroupedEvents -> { + val isExpanded = rememberSaveable(key = timelineItem.identifier()) { mutableStateOf(false) } + + fun onExpandGroupClick() { + isExpanded.value = !isExpanded.value + } + + Column(modifier = modifier.animateContentSize()) { + GroupHeaderView( + text = pluralStringResource( + id = R.plurals.room_timeline_state_changes, + count = timelineItem.events.size, + timelineItem.events.size + ), + isExpanded = isExpanded.value, + isHighlighted = !isExpanded.value && timelineItem.events.any { it.identifier() == highlightedItem }, + onClick = ::onExpandGroupClick, + ) + if (isExpanded.value) { + Column { + timelineItem.events.forEach { subGroupEvent -> + TimelineItemRow( + timelineItem = subGroupEvent, + highlightedItem = highlightedItem, + canReply = false, + onClick = onClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onUserDataClick = onUserDataClick, + onTimestampClicked = onTimestampClicked, + onReactionClick = onReactionClick, + onMoreReactionsClick = onMoreReactionsClick, + onSwipeToReply = {}, + ) + } + } + } + } + } + } +} + +@Composable +private fun BoxScope.TimelineScrollHelper( + lazyListState: LazyListState, + hasNewItems: Boolean, + onScrollFinishedAt: (Int) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } + val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } } + + LaunchedEffect(canAutoScroll, hasNewItems) { + val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems + if (shouldAutoScroll) { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } + } + + LaunchedEffect(isScrollFinished) { + if (isScrollFinished) { + // Notify the parent composable about the first visible item index when scrolling finishes + onScrollFinishedAt(lazyListState.firstVisibleItemIndex) + } + } + + JumpToBottomButton( + // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered + isVisible = !canAutoScroll, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 12.dp), + onClick = { + coroutineScope.launch { + if (lazyListState.firstVisibleItemIndex > 10) { + lazyListState.scrollToItem(0) + } else { + lazyListState.animateScrollToItem(0) + } + } + } + ) +} + +@Composable +private fun JumpToBottomButton( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = isVisible || LocalInspectionMode.current, + enter = scaleIn(animationSpec = tween(100)), + exit = scaleOut(animationSpec = tween(100)), + ) { + FloatingActionButton( + onClick = onClick, + elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), + shape = CircleShape, + modifier = Modifier.size(36.dp), + containerColor = ElementTheme.colors.bgSubtleSecondary, + contentColor = ElementTheme.colors.iconSecondary + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Filled.ArrowDownward, + contentDescription = "", + ) + } + } +} + +@DayNightPreviews +@Composable +fun TimelineViewPreview( + @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent +) = ElementPreview { + val timelineItems = aTimelineItemList(content) + TimelineView( + state = aTimelineState(timelineItems), + onMessageClicked = {}, + onTimestampClicked = {}, + onUserDataClicked = {}, + onMessageLongClicked = {}, + onReactionClicked = { _, _ -> }, + onMoreReactionsClicked = {}, + onSwipeToReply = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt new file mode 100644 index 0000000000..182965a4e5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vanniktech.emoji.Emoji +import com.vanniktech.emoji.google.GoogleEmojiProvider +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiPicker( + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + + val emojiProvider = remember { GoogleEmojiProvider() } + val categories = remember { emojiProvider.categories } + val pagerState = rememberPagerState() + Column (modifier) { + TabRow( + selectedTabIndex = pagerState.currentPage, + ) { + categories.forEachIndexed { index, category -> + Tab( + text = { + Icon( + resourceId = emojiProvider.getIcon(category), + contentDescription = category.categoryNames["en"] + ) + }, + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } + } + + HorizontalPager( + pageCount = categories.size, + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { index -> + val category = categories[index] + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Adaptive(minSize = 40.dp), + contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + items(category.emojis, key = { it.unicode }) { item -> + Box( + modifier = Modifier + .size(40.dp) + .clickable( + enabled = true, + onClick = { onEmojiSelected(item) }, + indication = rememberRipple(bounded = false, radius = 20.dp), + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = item.unicode, + style = ElementTheme.typography.fontHeadingSmRegular, + ) } + } + } + } + } +} + +@Preview +@Composable +internal fun EmojiPickerLightPreview() { + ElementPreviewLight { ContentToPreview() } +} + +@Preview +@Composable +internal fun EmojiPickerDarkPreview() { + ElementPreviewDark { ContentToPreview() } +} + +@Composable +private fun ContentToPreview() { + EmojiPicker( + onEmojiSelected = {}, + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt new file mode 100644 index 0000000000..932dce913c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState +import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider +import io.element.android.libraries.core.extensions.to01 +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.messageFromMeBackground +import io.element.android.libraries.designsystem.theme.messageFromOtherBackground +import io.element.android.libraries.theme.ElementTheme + +private val BUBBLE_RADIUS = 12.dp +private val BUBBLE_INCOMING_OFFSET = 16.dp + +// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now. +private const val BUBBLE_WIDTH_RATIO = 0.85f + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MessageEventBubble( + state: BubbleState, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + fun bubbleShape(): Shape { + return when (state.groupPosition) { + TimelineItemGroupPosition.First -> if (state.isMine) { + RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS) + } else { + RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) + } + TimelineItemGroupPosition.Middle -> if (state.isMine) { + RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS) + } else { + RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) + } + TimelineItemGroupPosition.Last -> if (state.isMine) { + RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS) + } else { + RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS) + } + TimelineItemGroupPosition.None -> + RoundedCornerShape( + BUBBLE_RADIUS, + BUBBLE_RADIUS, + BUBBLE_RADIUS, + BUBBLE_RADIUS + ) + } + } + + fun Modifier.offsetForItem(): Modifier { + return if (state.isMine) { + this + } else { + offset(x = BUBBLE_INCOMING_OFFSET) + } + } + + // Ignore state.isHighlighted for now, we need a design decision on it. + val backgroundBubbleColor = if (state.isMine) { + ElementTheme.colors.messageFromMeBackground + } else { + ElementTheme.colors.messageFromOtherBackground + } + val bubbleShape = bubbleShape() + Box( + modifier = modifier + .fillMaxWidth(BUBBLE_WIDTH_RATIO) + .padding(horizontal = 16.dp) + .offsetForItem(), + // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case + // when content width is low. + contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart + ) { + Surface( + modifier = Modifier + .widthIn(min = 80.dp) + .clip(bubbleShape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = rememberRipple(), + interactionSource = interactionSource + ), + color = backgroundBubbleColor, + shape = bubbleShape, + content = content + ) + } +} + +@Preview +@Composable +internal fun MessageEventBubbleLightPreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun MessageEventBubbleDarkPreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: BubbleState) { + // Due to position offset, surround with a Box + Box( + modifier = Modifier + .size(width = 240.dp, height = 64.dp) + .padding(vertical = 8.dp), + contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart, + ) { + MessageEventBubble( + state = state, + interactionSource = MutableInteractionSource(), + ) { + // Render the state as a text to better understand the previews + Box( + modifier = Modifier + .size(width = 120.dp, height = 32.dp) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "${state.groupPosition.javaClass.simpleName} m:${state.isMine.to01()} h:${state.isHighlighted.to01()}", + style = ElementTheme.typography.fontBodyXsRegular, + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt new file mode 100644 index 0000000000..4bb66f0eac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Surface + +private val CORNER_RADIUS = 8.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MessageStateEventContainer( + @Suppress("UNUSED_PARAMETER") isHighlighted: Boolean, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + // Ignore isHighlighted for now, we need a design decision on it. + val backgroundColor = Color.Transparent + val shape = RoundedCornerShape(CORNER_RADIUS) + Surface( + modifier = modifier + .widthIn(min = 80.dp) + .clip(shape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = rememberRipple(), + interactionSource = interactionSource + ), + color = backgroundColor, + shape = shape, + content = content + ) +} + +@Preview +@Composable +internal fun MessageStateEventContainerLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun MessageStateEventContainerDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + MessageStateEventContainer( + isHighlighted = false, + interactionSource = MutableInteractionSource(), + ) { + Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) + } + MessageStateEventContainer( + isHighlighted = true, + interactionSource = MutableInteractionSource(), + ) { + Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt new file mode 100644 index 0000000000..8d2eb06f99 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider +import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun MessagesReactionButton( + onClick: () -> Unit, + content: MessagesReactionsButtonContent, + modifier: Modifier = Modifier, +) { + val buttonColor = if (content.isHighlighted) { + ElementTheme.colors.bgSubtlePrimary + } else { + ElementTheme.colors.bgSubtleSecondary + } + + val borderColor = if (content.isHighlighted) { + ElementTheme.colors.borderInteractivePrimary + } else { + buttonColor + } + + Surface( + modifier = modifier + .background(Color.Transparent) + // Outer border, same colour as background + .border( + BorderStroke(2.dp, MaterialTheme.colorScheme.background), + shape = RoundedCornerShape(corner = CornerSize(14.dp)) + ) + .padding(vertical = 2.dp, horizontal = 2.dp) + // Clip click indicator inside the outer border + .clip(RoundedCornerShape(corner = CornerSize(12.dp))) + .clickable(onClick = onClick) + // Inner border, to highlight when selected + .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) + .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) + .padding(vertical = 4.dp, horizontal = 10.dp), + color = buttonColor + ) { + when (content) { + is MessagesReactionsButtonContent.Icon -> IconContent(imageVector = content.imageVector) + is MessagesReactionsButtonContent.Text -> TextContent(text = content.text) + is MessagesReactionsButtonContent.Reaction -> ReactionContent(reaction = content.reaction) + } + } +} + +sealed class MessagesReactionsButtonContent { + data class Text(val text: String) : MessagesReactionsButtonContent() + data class Icon(val imageVector: ImageVector) : MessagesReactionsButtonContent() + + data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent() + + val isHighlighted get() = this is Reaction && reaction.isHighlighted +} + +private val reactionEmojiLineHeight = 20.sp + +@Composable +private fun TextContent( + text: String, + modifier: Modifier = Modifier, +) = Text( + modifier = modifier + .height(reactionEmojiLineHeight.toDp()), + text = text, + style = ElementTheme.typography.fontBodyMdRegular, +) + +@Composable +private fun IconContent( + imageVector: ImageVector, + modifier: Modifier = Modifier +) = Icon( + imageVector = imageVector, + contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction), + tint = MaterialTheme.colorScheme.secondary, + modifier = modifier + .size(reactionEmojiLineHeight.toDp()) +) + +@Composable +private fun ReactionContent( + reaction: AggregatedReaction, + modifier: Modifier = Modifier, +) = Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, +) { + Text( + text = reaction.displayKey, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 15.sp, + lineHeight = reactionEmojiLineHeight, + ), + ) + if (reaction.count > 1) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = reaction.count.toString(), + color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } +} + +@DayNightPreviews +@Composable +internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction(reaction), + onClick = {} + ) +} + +@DayNightPreviews +@Composable +internal fun MessagesReactionExtraButtonsPreview() = ElementPreview { + Row { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = {} + ) + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text("12 more"), + onClick = {} + ) + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction( + aTimelineItemReactions().reactions.first().copy( + key = "A very long reaction with many characters that should be truncated" + ) + ), + onClick = {} + ) + } +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt new file mode 100644 index 0000000000..de40ae8dc1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon + +/** + * A swipe indicator that appears when swiping to reply to a message. + * + * @param swipeProgress the progress of the swipe, between 0 and X. When swipeProgress >= 1 the swipe will be detected. + * @param modifier the modifier to apply to this Composable root. + */ +@Composable +fun RowScope.ReplySwipeIndicator( + swipeProgress: () -> Float, + modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier + .align(Alignment.CenterVertically) + .graphicsLayer { + translationX = 36.dp.toPx() * swipeProgress().coerceAtMost(1f) + alpha = swipeProgress() + }, + contentDescription = null, + resourceId = VectorIcons.Reply, + ) +} + +@Preview +@Composable +internal fun ReplySwipeIndicatorLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun ReplySwipeIndicatorDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(modifier = Modifier.fillMaxWidth()) { + for (i in 0..8) { + Row { ReplySwipeIndicator(swipeProgress = { i / 8f }) } + } + Row { ReplySwipeIndicator(swipeProgress = { 1.5f }) } + Row { ReplySwipeIndicator(swipeProgress = { 2f }) } + Row { ReplySwipeIndicator(swipeProgress = { 3f }) } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt new file mode 100644 index 0000000000..f0a2c3473b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TimelineEventTimestampView( + event: TimelineItem.Event, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val formattedTime = event.sentTime + val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed + val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse() + val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null + val clickModifier = if (hasMessageSendingFailed) { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = rememberRipple(bounded = false), + interactionSource = MutableInteractionSource() + ) + } else { + Modifier + } + Row( + modifier = Modifier + .then(clickModifier) + .padding(start = 16.dp) // Add extra padding for touch target size + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isMessageEdited) { + Text( + stringResource(CommonStrings.common_edited_suffix), + style = ElementTheme.typography.fontBodyXsRegular, + color = tint ?: MaterialTheme.colorScheme.secondary, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + formattedTime, + style = ElementTheme.typography.fontBodyXsRegular, + color = tint ?: MaterialTheme.colorScheme.secondary, + ) + if (hasMessageSendingFailed && tint != null) { + Spacer(modifier = Modifier.width(2.dp)) + Icon(imageVector = Icons.Default.Error, contentDescription = "Error sending message", tint = tint, modifier = Modifier.size(15.dp, 18.dp)) + } + } +} + +@Preview +@Composable +internal fun TimelineEventTimestampViewLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = + ElementPreviewLight { ContentToPreview(event) } + +@Preview +@Composable +internal fun TimelineEventTimestampViewDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = + ElementPreviewDark { ContentToPreview(event) } + +@Composable +private fun ContentToPreview(event: TimelineItem.Event) { + TimelineEventTimestampView( + event = event, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt new file mode 100644 index 0000000000..7697ccf4a4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState + +class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> { + override val values: Sequence<TimelineItem.Event> + get() = sequenceOf( + aTimelineItemEvent(), + // Sending failed + aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")), + // Edited + aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)), + // Sending failed + Edited (not sure this is possible IRL, but should be covered by test) + aTimelineItemEvent().copy( + localSendState = LocalEventSendState.SendingFailed("AN_ERROR"), + content = aTimelineItemTextContent().copy(isEdited = true), + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt new file mode 100644 index 0000000000..fbb745c34e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -0,0 +1,774 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.constraintlayout.compose.ConstrainScope +import androidx.constraintlayout.compose.ConstraintLayout +import com.google.accompanist.flowlayout.FlowMainAxisAlignment +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.designsystem.components.EqualWidthColumn +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.swipe.SwipeableActionsState +import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch +import org.jsoup.Jsoup +import kotlin.math.abs +import kotlin.math.roundToInt + +@Composable +fun TimelineItemEventRow( + event: TimelineItem.Event, + isHighlighted: Boolean, + canReply: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onUserDataClick: (UserId) -> Unit, + inReplyToClick: (EventId) -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, + onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, + onSwipeToReply: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + + fun onUserDataClicked() { + onUserDataClick(event.senderId) + } + + fun inReplyToClicked() { + val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return + inReplyToClick(inReplyToEventId) + } + + Column(modifier = modifier.fillMaxWidth()) { + if (event.groupPosition.isNew()) { + Spacer(modifier = Modifier.height(16.dp)) + } else { + Spacer(modifier = Modifier.height(2.dp)) + } + if (canReply) { + val state: SwipeableActionsState = rememberSwipeableActionsState() + val offset = state.offset.value + val swipeThresholdPx = 40.dp.toPx() + val thresholdCrossed = abs(offset) > swipeThresholdPx + SwipeSensitivity(3f) { + Box(Modifier.fillMaxWidth()) { + Row(modifier = Modifier.matchParentSize()) { + ReplySwipeIndicator({ offset / 120 }) + } + TimelineItemEventRowContent( + modifier = Modifier + .absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) } + .draggable( + orientation = Orientation.Horizontal, + enabled = !state.isResettingOnRelease, + onDragStopped = { + coroutineScope.launch { + if (thresholdCrossed) { + onSwipeToReply() + } + state.resetOffset() + } + }, + state = state.draggableState, + ), + event = event, + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + onTimestampClicked = onTimestampClicked, + inReplyToClicked = ::inReplyToClicked, + onUserDataClicked = ::onUserDataClicked, + onReactionClicked = { emoji -> onReactionClick(emoji, event) }, + onMoreReactionsClicked = { onMoreReactionsClick(event) }, + ) + } + } + } else { + TimelineItemEventRowContent( + event = event, + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + onTimestampClicked = onTimestampClicked, + inReplyToClicked = ::inReplyToClicked, + onUserDataClicked = ::onUserDataClicked, + onReactionClicked = { emoji -> onReactionClick(emoji, event) }, + onMoreReactionsClicked = { onMoreReactionsClick(event) }, + ) + } + } +} + +/** + * Impact ViewConfiguration.touchSlop by [sensitivityFactor]. + * Inspired from https://issuetracker.google.com/u/1/issues/269627294. + * @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will + * have to drag to start the drag. + * @param content the content to display. + */ +@Composable +fun SwipeSensitivity( + sensitivityFactor: Float, + content: @Composable () -> Unit, +) { + val current = LocalViewConfiguration.current + CompositionLocalProvider( + LocalViewConfiguration provides object : ViewConfiguration by current { + override val touchSlop: Float + get() = current.touchSlop * sensitivityFactor + } + ) { + content() + } +} + +@Composable +private fun TimelineItemEventRowContent( + event: TimelineItem.Event, + isHighlighted: Boolean, + interactionSource: MutableInteractionSource, + onClick: () -> Unit, + onLongClick: () -> Unit, + onTimestampClicked: (TimelineItem.Event) -> Unit, + inReplyToClicked: () -> Unit, + onUserDataClicked: () -> Unit, + onReactionClicked: (emoji: String) -> Unit, + onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) { + end.linkTo(parent.end) + } else { + start.linkTo(parent.start) + } + + ConstraintLayout( + modifier = modifier + .wrapContentHeight() + .fillMaxWidth(), + ) { + val (sender, message, reactions) = createRefs() + + // Sender + val avatarStrokeSize = 3.dp + if (event.showSenderInformation) { + MessageSenderInformation( + event.safeSenderName, + event.senderAvatar, + avatarStrokeSize, + Modifier + .constrainAs(sender) { + top.linkTo(parent.top) + } + .padding(horizontal = 16.dp) + .zIndex(1f) + .clickable(onClick = onUserDataClicked) + ) + } + + // Message bubble + val bubbleState = BubbleState( + groupPosition = event.groupPosition, + isMine = event.isMine, + isHighlighted = isHighlighted, + ) + MessageEventBubble( + modifier = Modifier + .constrainAs(message) { + top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp) + this.linkStartOrEnd(event) + }, + state = bubbleState, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + ) { + MessageEventBubbleContent( + event = event, + interactionSource = interactionSource, + onMessageClick = onClick, + onMessageLongClick = onLongClick, + inReplyToClick = inReplyToClicked, + onTimestampClicked = { + onTimestampClicked(event) + } + ) + } + + // Reactions + if (event.reactionsState.reactions.isNotEmpty()) { + TimelineItemReactions( + reactionsState = event.reactionsState, + mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start, + onReactionClicked = onReactionClicked, + onMoreReactionsClicked = { onMoreReactionsClicked(event) }, + modifier = Modifier + .constrainAs(reactions) { + top.linkTo(message.bottom, margin = (-4).dp) + this.linkStartOrEnd(event) + } + .zIndex(1f) + .padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp) + ) + } + } +} + +@Composable +private fun MessageSenderInformation( + sender: String, + senderAvatar: AvatarData, + avatarStrokeSize: Dp, + modifier: Modifier = Modifier +) { + val avatarStrokeColor = MaterialTheme.colorScheme.background + val avatarSize = senderAvatar.size.dp + Box( + modifier = modifier + ) { + // Background of Avatar, to erase the corner of the message content + Canvas( + modifier = Modifier + .size(size = avatarSize + avatarStrokeSize) + .clipToBounds() + ) { + drawCircle( + color = avatarStrokeColor, + center = Offset(x = (avatarSize / 2).toPx(), y = (avatarSize / 2).toPx()), + radius = (avatarSize / 2 + avatarStrokeSize).toPx() + ) + } + // Content + Row { + Avatar(senderAvatar) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = sender, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyMdMedium, + ) + } + } +} + +@Composable +private fun MessageEventBubbleContent( + event: TimelineItem.Event, + interactionSource: MutableInteractionSource, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, + inReplyToClick: () -> Unit, + onTimestampClicked: () -> Unit, + modifier: Modifier = Modifier +) { + val isMediaItem = event.content is TimelineItemImageContent + || event.content is TimelineItemVideoContent + || event.content is TimelineItemLocationContent + val replyToDetails = event.inReplyTo as? InReplyTo.Ready + + // Long clicks are not not automatically propagated from a `clickable` + // to its `combinedClickable` parent so we do it manually + fun onTimestampLongClick() = onMessageLongClick() + + @Composable + fun ContentView( + modifier: Modifier = Modifier + ) { + TimelineItemEventContentView( + content = event.content, + interactionSource = interactionSource, + onClick = onMessageClick, + onLongClick = onMessageLongClick, + extraPadding = event.toExtraPadding(), + modifier = modifier, + ) + } + + @Composable + fun ContentAndTimestampView( + overlayTimestamp: Boolean, + modifier: Modifier = Modifier, + contentModifier: Modifier = Modifier, + timestampModifier: Modifier = Modifier, + ) { + if (overlayTimestamp) { + Box(modifier) { + ContentView(modifier = contentModifier) + TimelineEventTimestampView( + event = event, + onClick = onTimestampClicked, + onLongClick = ::onTimestampLongClick, + modifier = timestampModifier + .padding(horizontal = 4.dp, vertical = 4.dp) // Outer padding + .background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp)) + .align(Alignment.BottomEnd) + .padding(horizontal = 4.dp, vertical = 2.dp) // Inner padding + ) + } + } else { + Box(modifier) { + ContentView(modifier = contentModifier) + TimelineEventTimestampView( + event = event, + onClick = onTimestampClicked, + onLongClick = ::onTimestampLongClick, + modifier = timestampModifier + .align(Alignment.BottomEnd) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + + /** Used only for media items, with no reply to metadata. It displays the contents with no paddings. */ + @Composable + fun SimpleMediaItemLayout(modifier: Modifier = Modifier) { + ContentAndTimestampView(overlayTimestamp = true, modifier = modifier) + } + + /** Used for every other type of message, groups the different components in a Column with some space between them. */ + @Composable + fun CommonLayout( + inReplyToDetails: InReplyTo.Ready?, + modifier: Modifier = Modifier + ) { + EqualWidthColumn(modifier = modifier, spacing = 8.dp) { + if (inReplyToDetails != null) { + val senderName = inReplyToDetails.senderDisplayName ?: inReplyToDetails.senderId.value + val attachmentThumbnailInfo = attachmentThumbnailInfoForInReplyTo(inReplyToDetails) + val text = textForInReplyTo(inReplyToDetails) + ReplyToContent( + senderName = senderName, + text = text, + attachmentThumbnailInfo = attachmentThumbnailInfo, + modifier = Modifier + .padding(top = 8.dp, start = 8.dp, end = 8.dp) + .clip(RoundedCornerShape(6.dp)) + .clickable(enabled = true, onClick = inReplyToClick), + ) + } + val modifierWithPadding = if (isMediaItem) { + Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + } else { + Modifier + } + + val contentModifier = if (isMediaItem) { + Modifier.clip(RoundedCornerShape(12.dp)) + } else { + if (inReplyToDetails != null) { + Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp) + } else { + Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp) + } + } + + ContentAndTimestampView( + overlayTimestamp = isMediaItem, + contentModifier = contentModifier, + modifier = modifierWithPadding, + ) + } + } + + if (isMediaItem && replyToDetails == null) { + SimpleMediaItemLayout() + } else { + CommonLayout(inReplyToDetails = replyToDetails, modifier = modifier) + } +} + +@Composable +private fun ReplyToContent( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + modifier: Modifier = Modifier, +) { + val paddings = if (attachmentThumbnailInfo != null) { + PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + } else { + PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + } + Row( + modifier + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Column(verticalArrangement = Arrangement.SpaceBetween) { + Text( + text = senderName, + style = ElementTheme.typography.fontBodySmMedium, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = text.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) = + when (val type = inReplyTo.content.type) { + is ImageMessageType -> AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Image, + blurHash = type.info?.blurhash, + ) + is VideoMessageType -> AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Video, + blurHash = type.info?.blurhash, + ) + is FileMessageType -> AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource, + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.File, + ) + is LocationMessageType -> AttachmentThumbnailInfo( + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Location, + ) + is AudioMessageType -> AttachmentThumbnailInfo( + textContent = inReplyTo.content.body, + type = AttachmentThumbnailType.Audio, + ) + else -> null + } + +@Composable +private fun textForInReplyTo(inReplyTo: InReplyTo.Ready) = + when (inReplyTo.content.type) { + is LocationMessageType -> stringResource(CommonStrings.common_shared_location) + else -> inReplyTo.content.body + } + +@Preview +@Composable +internal fun TimelineItemEventRowLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemEventRowDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + sequenceOf(false, true).forEach { + TimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemTextContent().copy( + body = "A long text which will be displayed on several lines and" + + " hopefully can be manually adjusted to test different behaviors." + ), + groupPosition = TimelineItemGroupPosition.First, + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onTimestampClicked = {}, + onSwipeToReply = {}, + ) + TimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemImageContent().copy( + aspectRatio = 5f + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onTimestampClicked = {}, + onSwipeToReply = {}, + ) + } + } +} + +@Preview +@Composable +internal fun TimelineItemEventRowWithReplyLightPreview() = + ElementPreviewLight { ContentToPreviewWithReply() } + +@Preview +@Composable +internal fun TimelineItemEventRowWithReplyDarkPreview() = + ElementPreviewDark { ContentToPreviewWithReply() } + +@Composable +private fun ContentToPreviewWithReply() { + Column { + sequenceOf(false, true).forEach { + val replyContent = if (it) { + // Short + "Message which are being replied." + } else { + // Long, to test 2 lines and ellipsis) + "Message which are being replied, and which was long enough to be displayed on two lines (only!)." + } + TimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemTextContent().copy( + body = "A long text which will be displayed on several lines and" + + " hopefully can be manually adjusted to test different behaviors." + ), + inReplyTo = aInReplyToReady(replyContent), + groupPosition = TimelineItemGroupPosition.First, + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onTimestampClicked = {}, + onSwipeToReply = {}, + ) + TimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemImageContent().copy( + aspectRatio = 5f + ), + inReplyTo = aInReplyToReady(replyContent), + groupPosition = TimelineItemGroupPosition.Last, + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onTimestampClicked = {}, + onSwipeToReply = {}, + ) + } + } +} + +private fun aInReplyToReady( + replyContent: String +): InReplyTo.Ready { + return InReplyTo.Ready( + eventId = EventId("\$event"), + content = MessageContent(replyContent, null, false, TextMessageType(replyContent, null)), + senderId = UserId("@Sender:domain"), + senderDisplayName = "Sender", + senderAvatarUrl = null, + ) +} + +@Preview +@Composable +internal fun TimelineItemEventRowTimestampLightPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = + ElementPreviewLight { ContentTimestampToPreview(event) } + +@Preview +@Composable +internal fun TimelineItemEventRowTimestampDarkPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = + ElementPreviewDark { ContentTimestampToPreview(event) } + +@Composable +private fun ContentTimestampToPreview(event: TimelineItem.Event) { + Column { + val oldContent = event.content as TimelineItemTextContent + listOf( + "Text", + "Text longer, displayed on 1 line", + "Text which should be rendered on several lines", + ).forEach { str -> + listOf(false, true).forEach { useDocument -> + TimelineItemEventRow( + event = event.copy( + content = oldContent.copy( + body = str, + htmlDocument = if (useDocument) Jsoup.parse(str) else null, + ), + reactionsState = aTimelineItemReactions(count = 0), + senderDisplayName = if (useDocument) "Document case" else "Text case", + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onTimestampClicked = {}, + onSwipeToReply = {}, + ) + } + } + } +} + +@Preview +@Composable +internal fun TimelineItemEventRowWithManyReactionsLightPreview() = + ElementPreviewLight { ContentWithManyReactionsToPreview() } + +@Preview +@Composable +internal fun TimelineItemEventRowWithManyReactionsDarkPreview() = + ElementPreviewDark { ContentWithManyReactionsToPreview() } + +@Composable +private fun ContentWithManyReactionsToPreview() { + Column { + listOf(false, true).forEach { isMine -> + TimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemTextContent().copy( + body = "A couple of multi-line messages with many reactions attached." + + " One sent by me and another from someone else." + ), + timelineItemReactions = aTimelineItemReactions(count = 20), + ), + isHighlighted = false, + canReply = true, + onClick = {}, + onLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onMoreReactionsClick = {}, + onSwipeToReply = {}, + onTimestampClicked = {}, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt new file mode 100644 index 0000000000..ca0a58da70 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.accompanist.flowlayout.FlowMainAxisAlignment +import com.google.accompanist.flowlayout.FlowRow +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +/** + * The maximum number of items that can be displayed before some items will be hidden + * + * TODO The threshold should be based on the number of rows, rather than items. + * Once items would spill onto a third row, they should be hidden. + * Note this could be particularly worthwhile to handle reactions that are + * longer than a single character (as annotation keys are free text). + */ +private const val COLLAPSE_ITEMS_THRESHOLD = 8 + +@Composable +fun TimelineItemReactions( + reactionsState: TimelineItemReactions, + mainAxisAlignment: FlowMainAxisAlignment, + onReactionClicked: (emoji: String) -> Unit, + onMoreReactionsClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded: Boolean by rememberSaveable { mutableStateOf(false) } + + val reactions by remember(reactionsState, expanded) { + derivedStateOf { + val numToDisplay = if (expanded) { + reactionsState.reactions.count() + } else { + COLLAPSE_ITEMS_THRESHOLD + } + reactionsState.reactions.take(numToDisplay).toPersistentList() + } + } + + val expandableState by remember { + derivedStateOf { + if (expanded) { + ExpandableState.Expanded + } else { + val hiddenItems = reactionsState.reactions.count() - reactions.count() + if (hiddenItems > 0) { + ExpandableState.Collapsed(hidden = hiddenItems) + } else { + ExpandableState.None + } + } + } + } + + TimelineItemReactionsView( + modifier = modifier, + reactions = reactions, + expandableState = expandableState, + mainAxisAlignment = mainAxisAlignment, + onReactionClick = onReactionClicked, + onMoreReactionsClick = onMoreReactionsClicked, + onExpandClick = { expanded = true }, + onCollapseClick = { expanded = false } + ) +} + +private sealed class ExpandableState { + object None: ExpandableState() + data class Collapsed(val hidden: Int): ExpandableState() + object Expanded : ExpandableState() +} + +@Composable +private fun TimelineItemReactionsView( + reactions: ImmutableList<AggregatedReaction>, + expandableState: ExpandableState, + mainAxisAlignment: FlowMainAxisAlignment, + onReactionClick: (emoji: String) -> Unit, + onMoreReactionsClick: () -> Unit, + onExpandClick: () -> Unit, + onCollapseClick: () -> Unit, + modifier: Modifier = Modifier +) = + FlowRow( + modifier = modifier, + mainAxisSpacing = 4.dp, + crossAxisSpacing = 4.dp, + mainAxisAlignment = mainAxisAlignment, + ) { + reactions.forEach { reaction -> + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction(reaction = reaction), + onClick = { onReactionClick(reaction.key) } + ) + } + when (expandableState) { + ExpandableState.Expanded -> + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource(id = R.string.screen_room_timeline_less_reactions) + ), + onClick = onCollapseClick, + ) + is ExpandableState.Collapsed -> { + val hidden = expandableState.hidden + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden) + ), + onClick = onExpandClick, + ) + } + ExpandableState.None -> { + // No expand or collapse action available + } + } + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = onMoreReactionsClick + ) + } + +@DayNightPreviews +@Composable +fun TimelineItemReactionsViewPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 1).reactions, + expandableState = ExpandableState.None, + ) +} + +@DayNightPreviews +@Composable +fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 3).reactions, + expandableState = ExpandableState.Collapsed(hidden = 7), + ) +} + +@DayNightPreviews +@Composable +fun TimelineItemReactionsViewExpandedPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 10).reactions, + expandableState = ExpandableState.Expanded, + ) +} + +@Composable +private fun ContentToPreview( + reactions: ImmutableList<AggregatedReaction>, + expandableState: ExpandableState +) { + TimelineItemReactionsView( + reactions = reactions, + expandableState = expandableState, + mainAxisAlignment = FlowMainAxisAlignment.Center, + onReactionClick = {}, + onMoreReactionsClick = {}, + onExpandClick = {}, + onCollapseClick = {} + ) +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt new file mode 100644 index 0000000000..d22182d098 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.util.defaultTimelineContentPadding +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun TimelineItemStateEventRow( + event: TimelineItem.Event, + isHighlighted: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + MessageStateEventContainer( + isHighlighted = isHighlighted, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + TimelineItemEventContentView( + content = event.content, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + extraPadding = noExtraPadding, + modifier = Modifier.defaultTimelineContentPadding() + ) + } + } +} + +@Preview +@Composable +internal fun TimelineItemStateEventRowLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemStateEventRowDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemStateEventRow( + event = aTimelineItemEvent( + isMine = false, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ), + isHighlighted = false, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt new file mode 100644 index 0000000000..d6b1c06f54 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel + +@Composable +fun TimelineItemVirtualRow( + virtual: TimelineItem.Virtual, + modifier: Modifier = Modifier +) { + when (virtual.model) { + is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) + TimelineItemReadMarkerModel -> return + is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier) + } +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt new file mode 100644 index 0000000000..70c2a7dc10 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.vanniktech.emoji.Emoji +import io.element.android.features.messages.impl.timeline.components.EmojiPicker +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomReactionBottomSheet( + state: CustomReactionState, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + fun onDismiss() { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + } + + fun onEmojiSelectedDismiss(emoji: Emoji) { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + onEmojiSelected(emoji) + } + } + + val isVisible = state.selectedEventId != null + if (isVisible) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + EmojiPicker( + onEmojiSelected = ::onEmojiSelectedDismiss, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt new file mode 100644 index 0000000000..b7c210553e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface CustomReactionEvents { + data class UpdateSelectedEvent(val eventId: EventId?) : CustomReactionEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt new file mode 100644 index 0000000000..0a23d42085 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import javax.inject.Inject + +class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionState> { + + @Composable + override fun present(): CustomReactionState { + var selectedEventId by remember { mutableStateOf<EventId?>(null) } + + fun handleEvents(event: CustomReactionEvents) { + when (event) { + is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId + } + } + + return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt new file mode 100644 index 0000000000..6c0c7f3599 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +data class CustomReactionState( + val selectedEventId: EventId?, + val eventSink: (CustomReactionEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt new file mode 100644 index 0000000000..d941b8a814 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.TextUnit +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +// Allow to not overlap the timestamp with the text, in the message bubble. +// Compute the size of the worst case. +data class ExtraPadding(val nbChars: Int) + +val noExtraPadding = ExtraPadding(0) + +/** + * See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View. + * And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design. + */ +@Composable +fun TimelineItem.Event.toExtraPadding(): ExtraPadding { + val formattedTime = sentTime + val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed + val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse() + + var strLen = 6 + if (isMessageEdited) { + strLen += stringResource(id = CommonStrings.common_edited_suffix).length + 3 + } + strLen += formattedTime.length + if (hasMessageSendingFailed) { + strLen += 5 + if (isMessageEdited) { + // I do not know why, but adding 2 more chars avoid overlapping when the + // message is edited and in error. + strLen += 2 + } + } + return ExtraPadding(strLen) +} + +/** + * Get a string to add to the content of the message to avoid overlapping the timestamp. + * @param fontSize the font size of the message content, to be able to add more space char if the font is small. + */ +fun ExtraPadding.getStr(fontSize: TextUnit): String { + if (nbChars == 0) return "" + val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp + val nbOfSpaces = ((timestampFontSize.value / fontSize.value) * nbChars).toInt() + 1 + // A space and some unbreakable spaces + return " " + "\u00A0".repeat(nbOfSpaces) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt new file mode 100644 index 0000000000..f733913bc8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.heightIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private const val MAX_HEIGHT_IN_DP = 360f +private const val MIN_ASPECT_RATIO = 0.6f +private const val MAX_ASPECT_RATIO = 4f +private const val DEFAULT_ASPECT_RATIO = 1.33f + +@Composable +fun TimelineItemAspectRatioBox( + aspectRatio: Float?, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + content: @Composable (BoxScope.() -> Unit), +) { + val safeAspectRatio = (aspectRatio ?: DEFAULT_ASPECT_RATIO).coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + Box( + modifier = modifier + .heightIn(max = MAX_HEIGHT_IN_DP.dp) + .aspectRatio(safeAspectRatio, true), + contentAlignment = contentAlignment, + content = content + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt new file mode 100644 index 0000000000..f96290cb51 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineItemAudioView( + content: TimelineItemAudioContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(ElementTheme.materialColors.background), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.GraphicEq, + contentDescription = null, + tint = ElementTheme.materialColors.primary, + modifier = Modifier + .size(16.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Column { + Text( + text = content.body, + color = ElementTheme.materialColors.primary, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis + ) + Text( + text = content.fileExtensionAndSize + extraPadding.getStr(12.sp), + color = ElementTheme.materialColors.secondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@DayNightPreviews +@Composable +internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) = + ElementPreview { + TimelineItemAudioView( + content, + extraPadding = noExtraPadding, + ) + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt new file mode 100644 index 0000000000..3df45eb760 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent + +@Composable +fun TimelineItemEventContentView( + content: TimelineItemEventContent, + interactionSource: MutableInteractionSource, + extraPadding: ExtraPadding, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + when (content) { + is TimelineItemEncryptedContent -> TimelineItemEncryptedView( + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + is TimelineItemRedactedContent -> TimelineItemRedactedView( + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + is TimelineItemTextBasedContent -> TimelineItemTextView( + content = content, + extraPadding = extraPadding, + interactionSource = interactionSource, + modifier = modifier, + onTextClicked = onClick, + onTextLongClicked = onLongClick + ) + is TimelineItemUnknownContent -> TimelineItemUnknownView( + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + is TimelineItemLocationContent -> TimelineItemLocationView( + content = content, + modifier = modifier + ) + is TimelineItemImageContent -> TimelineItemImageView( + content = content, + modifier = modifier, + ) + is TimelineItemVideoContent -> TimelineItemVideoView( + content = content, + modifier = modifier + ) + is TimelineItemFileContent -> TimelineItemFileView( + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + is TimelineItemAudioContent -> TimelineItemAudioView( + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + is TimelineItemStateContent -> TimelineItemStateView( + content = content, + modifier = modifier + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt new file mode 100644 index 0000000000..9755377b39 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemEncryptedView( + content: TimelineItemEncryptedContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier +) { + TimelineItemInformativeView( + text = stringResource(id = CommonStrings.common_decryption_error), + iconDescription = stringResource(id = CommonStrings.dialog_title_warning), + icon = Icons.Default.Warning, + extraPadding = extraPadding, + modifier = modifier + ) +} + +@Preview +@Composable +internal fun TimelineItemEncryptedViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemEncryptedViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemEncryptedView( + content = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.Unknown + ), + extraPadding = noExtraPadding + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt new file mode 100644 index 0000000000..edc2c37aab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineItemFileView( + content: TimelineItemFileContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(ElementTheme.materialColors.background), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = "OpenFile", + tint = ElementTheme.materialColors.primary, + modifier = Modifier + .size(16.dp) + .rotate(-45f), + ) + } + Spacer(Modifier.width(8.dp)) + Column { + Text( + text = content.body, + color = ElementTheme.materialColors.primary, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis + ) + Text( + text = content.fileExtensionAndSize + extraPadding.getStr(12.sp), + color = ElementTheme.materialColors.secondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +internal fun TimelineItemFileViewLightPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = + ElementPreviewLight { ContentToPreview(content) } + +@Preview +@Composable +internal fun TimelineItemFileViewDarkPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = + ElementPreviewDark { ContentToPreview(content) } + +@Composable +private fun ContentToPreview(content: TimelineItemFileContent) { + TimelineItemFileView( + content, + extraPadding = noExtraPadding, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt new file mode 100644 index 0000000000..6c7b51ddfa --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.media.MediaRequestData + +@Composable +fun TimelineItemImageView( + content: TimelineItemImageContent, + modifier: Modifier = Modifier, +) { + TimelineItemAspectRatioBox( + aspectRatio = content.aspectRatio, + modifier = modifier + ) { + BlurHashAsyncImage( + model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), + blurHash = content.blurhash, + contentScale = ContentScale.Crop, + ) + } +} + +@Preview +@Composable +internal fun TimelineItemImageViewLightPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = + ElementPreviewLight { ContentToPreview(content) } + +@Preview +@Composable +internal fun TimelineItemImageViewDarkPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = + ElementPreviewDark { ContentToPreview(content) } + +@Composable +private fun ContentToPreview(content: TimelineItemImageContent) { + TimelineItemImageView(content) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt new file mode 100644 index 0000000000..83635ff7d8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineItemInformativeView( + text: String, + iconDescription: String, + icon: ImageVector, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + tint = MaterialTheme.colorScheme.secondary, + contentDescription = iconDescription, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = text + extraPadding.getStr(14.sp) + ) + } +} + +@Preview +@Composable +internal fun TimelineItemInformativeViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemInformativeViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemInformativeView( + text = "Info", + iconDescription = "", + icon = Icons.Default.Delete, + extraPadding = noExtraPadding, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt new file mode 100644 index 0000000000..ebc9636773 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.location.api.StaticMapView +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun TimelineItemLocationView( + content: TimelineItemLocationContent, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + content.description?.let { + Text( + text = it, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + ) + } + + StaticMapView( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + lat = content.location.lat, + lon = content.location.lon, + zoom = 15.0, + contentDescription = content.body + ) + } +} + +@DayNightPreviews +@Composable +internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) = + ElementPreview { + TimelineItemLocationView(content) + } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt new file mode 100644 index 0000000000..44917c7d5b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemRedactedView( + content: TimelineItemRedactedContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier +) { + TimelineItemInformativeView( + text = stringResource(id = CommonStrings.common_message_removed), + iconDescription = stringResource(id = CommonStrings.common_message_removed), + icon = Icons.Default.Delete, + extraPadding = extraPadding, + modifier = modifier + ) +} + +@Preview +@Composable +internal fun TimelineItemRedactedViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemRedactedViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemRedactedView( + TimelineItemRedactedContent, + extraPadding = noExtraPadding + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt new file mode 100644 index 0000000000..461cd13cee --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineItemStateView( + content: TimelineItemStateContent, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = content.body, + textAlign = TextAlign.Center, + ) +} + +@Preview +@Composable +internal fun TimelineItemStateViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemStateViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemStateView( + content = aTimelineItemStateEventContent(), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt new file mode 100644 index 0000000000..65be8f44e0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannableString +import android.text.style.URLSpan +import android.text.util.Linkify.PHONE_NUMBERS +import android.text.util.Linkify.WEB_URLS +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.text.util.LinkifyCompat +import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.theme.LinkColor +import io.element.android.libraries.designsystem.text.toAnnotatedString + +@Composable +fun TimelineItemTextView( + content: TimelineItemTextBasedContent, + interactionSource: MutableInteractionSource, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val htmlDocument = content.htmlDocument + if (htmlDocument != null) { + // For now we ignore the extra padding for html content, so add some spacing + // below the content (as previous behavior) + Column(modifier = modifier) { + HtmlDocument( + document = htmlDocument, + modifier = Modifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + Spacer(Modifier.height(16.dp)) + } + } else { + Box(modifier) { + val linkStyle = SpanStyle( + color = LinkColor, + ) + val styledText = remember(content.body) { + content.body.linkify(linkStyle) + extraPadding.getStr(16.sp).toAnnotatedString() + } + ClickableLinkText( + text = styledText, + linkAnnotationTag = "URL", + onClick = onTextClicked, + onLongClick = onTextLongClicked, + interactionSource = interactionSource + ) + } + } +} + +private fun String.linkify( + linkStyle: SpanStyle, +) = buildAnnotatedString { + append(this@linkify) + val spannable = SpannableString(this@linkify) + LinkifyCompat.addLinks(spannable, WEB_URLS or PHONE_NUMBERS) + + val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) + for (span in spans) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + addStyle( + start = start, + end = end, + style = linkStyle, + ) + addStringAnnotation( + tag = "URL", + annotation = span.url, + start = start, + end = end + ) + } +} + +@Preview +@Composable +internal fun TimelineItemTextViewLightPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) = + ElementPreviewLight { ContentToPreview(content) } + +@Preview +@Composable +internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) = + ElementPreviewDark { ContentToPreview(content) } + +@Composable +fun ContentToPreview(content: TimelineItemTextBasedContent) { + TimelineItemTextView( + content = content, + interactionSource = MutableInteractionSource(), + extraPadding = ExtraPadding(nbChars = 8), + ) +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt new file mode 100644 index 0000000000..852428cb92 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemUnknownView( + content: TimelineItemUnknownContent, + extraPadding: ExtraPadding, + modifier: Modifier = Modifier +) { + TimelineItemInformativeView( + text = stringResource(id = CommonStrings.common_unsupported_event), + iconDescription = stringResource(id = CommonStrings.dialog_title_warning), + icon = Icons.Default.Info, + extraPadding = extraPadding, + modifier = modifier + ) +} + +@Preview +@Composable +internal fun TimelineItemUnknownViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineItemUnknownViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineItemUnknownView( + content = TimelineItemUnknownContent, + extraPadding = noExtraPadding + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt new file mode 100644 index 0000000000..aeb2e7145e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage +import io.element.android.libraries.designsystem.modifiers.roundedBackground +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.media.MediaRequestData + +@Composable +fun TimelineItemVideoView( + content: TimelineItemVideoContent, + modifier: Modifier = Modifier, +) { + TimelineItemAspectRatioBox( + aspectRatio = content.aspectRatio, + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + BlurHashAsyncImage( + model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.File(content.body, content.mimeType)), + blurHash = content.blurHash, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier.roundedBackground(), + contentAlignment = Alignment.Center, + ) { + Image( + Icons.Default.PlayArrow, + contentDescription = "Play", + colorFilter = ColorFilter.tint(Color.White), + ) + } + } +} + +@Preview +@Composable +internal fun TimelineItemVideoViewLightPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = + ElementPreviewLight { ContentToPreview(content) } + +@Preview +@Composable +internal fun TimelineItemVideoViewDarkPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = + ElementPreviewDark { ContentToPreview(content) } + +@Composable +private fun ContentToPreview(content: TimelineItemVideoContent) { + TimelineItemVideoView(content) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt new file mode 100644 index 0000000000..b2d5dbc7f6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.group + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +private val CORNER_RADIUS = 8.dp + +@Composable +fun GroupHeaderView( + text: String, + isExpanded: Boolean, + @Suppress("UNUSED_PARAMETER") isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + // Ignore isHighlighted for now, we need a design decision on it. + val backgroundColor = Color.Companion.Transparent + val shape = RoundedCornerShape(CORNER_RADIUS) + + Box( + modifier = modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .clip(shape) + .clickable(onClick = onClick), + color = backgroundColor, + shape = shape, + ) { + Row( + modifier = Modifier + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + val icon = if (isExpanded) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + } + Icon(icon, "", tint = MaterialTheme.colorScheme.secondary) + } + } + } +} + +@Preview +@Composable +fun GroupHeaderViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun GroupHeaderViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + GroupHeaderView( + text = "8 room changes (expanded)", + isExpanded = true, + isHighlighted = false, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (not expanded)", + isExpanded = false, + isHighlighted = false, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (expanded/h)", + isExpanded = true, + isHighlighted = true, + onClick = {} + ) + GroupHeaderView( + text = "8 room changes (not expanded/h)", + isExpanded = false, + isHighlighted = true, + onClick = {} + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/DocumentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/DocumentProvider.kt new file mode 100644 index 0000000000..249334cace --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/DocumentProvider.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.html + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +open class DocumentProvider : PreviewParameterProvider<Document> { + override val values: Sequence<Document> + get() = sequenceOf( + "text", + "<strong>Strong</strong>", + "<b>Bold</b>", + "<i>Italic</i>", + // FIXME This does not work + "<b><i>Bold then italic</i></b>", + // FIXME This does not work + "<i><b>Italic then bold</b></i>", + "<em>em</em>", + "<unknown>unknown</unknown>", + // FIXME `br` is not rendered correctly in the Preview. + "Line 1<br/>Line 2", + "<code>code</code>", + "<del>del</del>", + "<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6><h7>Heading 7</h7>", + "<a href=\"https://matrix.org\">link</a>", + "<p>paragraph</p>", + "<p>paragraph 1</p><p>paragraph 2</p>", + "<ol><li>ol item 1</li><li>ol item 2</li></ol>", + "<ol><li><i>ol item 1 italic</i></li><li><b>ol item 2 bold</b></li></ol>", + "<ul><li>ul item 1</li><li>ul item 2</li></ul>", + "<blockquote>blockquote</blockquote>", + // TODO Find a way to make is work with `pre`. For now there is an error with + // jsoup: java.lang.NoSuchMethodError: 'org.jsoup.nodes.Element org.jsoup.nodes.Element.firstElementChild()' + // "<pre>pre</pre>", + "<mx-reply><blockquote><a href=\\\"https://matrix.to/#/!roomId/\$eventId?via=matrix.org\\\">In reply to</a> " + + "<a href=\\\"https://matrix.to/#/@alice:matrix.org\\\">@alice:matrix.org</a><br>original message</blockquote></mx-reply>reply", + ).map { Jsoup.parse(it) } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt new file mode 100644 index 0000000000..cb0264cf5d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.html + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.flowlayout.FlowRow +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.theme.LinkColor +import kotlinx.collections.immutable.persistentMapOf +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +private const val chipId = "chip" + +@Composable +fun HtmlDocument( + document: Document, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + HtmlBody( + body = document.body(), + interactionSource = interactionSource, + modifier = modifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + ) +} + +@Composable +private fun HtmlBody( + body: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + @Composable + fun NodesFlowRode( + nodes: Iterator<Node>, + interactionSource: MutableInteractionSource, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, + ) = FlowRow( + mainAxisSpacing = 2.dp, + crossAxisSpacing = 8.dp, + ) { + var sameRow = true + while (sameRow && nodes.hasNext()) { + when (val node = nodes.next()) { + is TextNode -> { + if (!node.isBlank) { + Text( + text = node.text(), + color = MaterialTheme.colorScheme.primary, + ) + } + } + is Element -> { + if (node.isInline()) { + HtmlInline( + node, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + } else { + HtmlBlock( + element = node, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + sameRow = false + } + } + else -> continue + } + } + } + + Column(modifier = modifier) { + val nodesIterator = body.childNodes().iterator() + while (nodesIterator.hasNext()) { + NodesFlowRode( + nodes = nodesIterator, + interactionSource = interactionSource, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + ) + } + } +} + +private fun Element.isInline(): Boolean { + return when (tagName().lowercase()) { + "del" -> true + "mx-reply" -> false + else -> !isBlock + } +} + +@Composable +private fun HtmlBlock( + element: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val blockModifier = modifier + .padding(top = 4.dp) + when (element.tagName().lowercase()) { + "p" -> HtmlParagraph( + paragraph = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + "h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading( + heading = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + "ol" -> HtmlOrderedList( + orderedList = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + "ul" -> HtmlUnorderedList( + unorderedList = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + "blockquote" -> HtmlBlockquote( + blockquote = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + "pre" -> HtmlPreformatted(element, blockModifier) + "mx-reply" -> HtmlMxReply( + mxReply = element, + modifier = blockModifier, + onTextClicked = onTextClicked, + onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + else -> return + } +} + +@Composable +private fun HtmlInline( + element: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + Box(modifier) { + val styledText = buildAnnotatedString { + appendInlineElement(element, MaterialTheme.colorScheme) + } + HtmlText( + text = styledText, + onClick = onTextClicked, + onLongClick = onTextLongClicked, + interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlPreformatted( + pre: Element, + modifier: Modifier = Modifier +) { + val isCode = pre.firstElementChild()?.tagName()?.lowercase() == "code" + val backgroundColor = + if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified + Box( + modifier + .background(color = backgroundColor) + .padding(horizontal = 8.dp) + ) { + Text( + text = pre.wholeText(), + style = TextStyle(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun HtmlParagraph( + paragraph: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + Box(modifier) { + val styledText = buildAnnotatedString { + appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme) + } + HtmlText( + text = styledText, onClick = onTextClicked, + onLongClick = onTextLongClicked, interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlBlockquote( + blockquote: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val color = MaterialTheme.colorScheme.onBackground + Box( + modifier = modifier + .drawBehind { + drawLine( + color = color, + strokeWidth = 2f, + start = Offset(12.dp.value, 0f), + end = Offset(12.dp.value, size.height) + ) + } + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp) + ) { + val text = buildAnnotatedString { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme) + } + } + HtmlText( + text = text, onClick = onTextClicked, + onLongClick = onTextLongClicked, interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlHeading( + heading: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val style = when (heading.tagName().lowercase()) { + "h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp) + "h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp) + "h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp) + "h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp) + "h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp) + "h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp) + else -> { + return + } + } + Box(modifier) { + val text = buildAnnotatedString { + appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme) + } + HtmlText( + text = text, + style = style, + onClick = onTextClicked, + onLongClick = onTextLongClicked, + interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlMxReply( + mxReply: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val blockquote = mxReply.childNodes().firstOrNull() ?: return + val shape = RoundedCornerShape(12.dp) + Surface( + modifier = modifier + .padding(bottom = 4.dp) + .offset(x = -(8.dp)), + color = MaterialTheme.colorScheme.background, + shape = shape, + ) { + val text = buildAnnotatedString { + for (blockquoteNode in blockquote.childNodes()) { + when (blockquoteNode) { + is TextNode -> { + withStyle( + style = SpanStyle( + fontSize = 12.sp, + color = MaterialTheme.colorScheme.secondary + ) + ) { + append(blockquoteNode.text()) + } + } + is Element -> { + when (blockquoteNode.tagName().lowercase()) { + "br" -> { + append('\n') + } + "a" -> { + append(blockquoteNode.ownText()) + } + } + } + } + } + } + HtmlText( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + onClick = onTextClicked, + onLongClick = onTextLongClicked, + interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlOrderedList( + orderedList: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + var number = 1 + val delimiter = "." + HtmlListItems( + list = orderedList, + modifier = modifier, + onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) { + val text = buildAnnotatedString { + append("${number++}$delimiter ${it.text()}") + } + HtmlText( + text = text, onClick = onTextClicked, + onLongClick = onTextLongClicked, interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlUnorderedList( + unorderedList: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, +) { + val marker = "・" + HtmlListItems( + list = unorderedList, + modifier = modifier, + onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) { + val text = buildAnnotatedString { + append("$marker ${it.text()}") + } + HtmlText( + text = text, onClick = onTextClicked, + onLongClick = onTextLongClicked, interactionSource = interactionSource + ) + } +} + +@Composable +private fun HtmlListItems( + list: Element, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + onTextClicked: () -> Unit = {}, + onTextLongClicked: () -> Unit = {}, + content: @Composable (node: TextNode) -> Unit = {} +) { + Column(modifier = modifier) { + for (node in list.children()) { + for (innerNode in node.childNodes()) { + when (innerNode) { + is TextNode -> { + if (!innerNode.isBlank) content(innerNode) + } + is Element -> HtmlBlock( + element = innerNode, + modifier = Modifier.padding(start = 4.dp), + onTextClicked = onTextClicked, onTextLongClicked = onTextLongClicked, + interactionSource = interactionSource + ) + } + } + } + } +} + +private fun ColorScheme.codeBackground(): Color { + return background.copy(alpha = 0.3f) +} + +private fun AnnotatedString.Builder.appendInlineChildrenElements( + childNodes: List<Node>, + colors: ColorScheme +) { + for (node in childNodes) { + when (node) { + is TextNode -> { + append(node.text()) + } + is Element -> { + appendInlineElement(node, colors) + } + } + } +} + +private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) { + when (element.tagName().lowercase()) { + "br" -> { + append('\n') + } + "code" -> { + withStyle( + style = TextStyle( + fontFamily = FontFamily.Monospace, + background = colors.codeBackground() + ).toSpanStyle() + ) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "del" -> { + withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "i", + "em" -> { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "strong" -> { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + appendInlineChildrenElements(element.childNodes(), colors) + } + } + "a" -> { + appendLink(element) + } + else -> { + appendInlineChildrenElements(element.childNodes(), colors) + } + } +} + +private fun AnnotatedString.Builder.appendLink(link: Element) { + val uriString = link.attr("href") + val permalinkData = PermalinkParser.parse(uriString) + when (permalinkData) { + is PermalinkData.FallbackLink -> { + pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString()) + withStyle( + style = SpanStyle(color = LinkColor) + ) { + append(link.ownText()) + } + pop() + } + is PermalinkData.RoomEmailInviteLink -> { + appendInlineContent(chipId, link.ownText()) + } + is PermalinkData.RoomLink -> { + appendInlineContent(chipId, link.ownText()) + } + is PermalinkData.UserLink -> { + appendInlineContent(chipId, link.ownText()) + } + } +} + +@Composable +private fun HtmlText( + text: AnnotatedString, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +) { + val inlineContentMap = persistentMapOf<String, InlineTextContent>() + ClickableLinkText( + text = text, + linkAnnotationTag = "URL", + style = style, + modifier = modifier, + inlineContent = inlineContentMap, + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick + ) +} + +@Preview +@Composable +internal fun HtmlDocumentLightPreview(@PreviewParameter(DocumentProvider::class) document: Document) = + ElementPreviewLight { ContentToPreview(document) } + +@Preview +@Composable +internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class) document: Document) = + ElementPreviewDark { ContentToPreview(document) } + +@Composable +private fun ContentToPreview(document: Document) { + HtmlDocument(document, MutableInteractionSource()) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt new file mode 100644 index 0000000000..ab6e32f078 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface RetrySendMenuEvents { + data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents + object RetrySend : RetrySendMenuEvents + object RemoveFailed : RetrySendMenuEvents + object Dismiss: RetrySendMenuEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt new file mode 100644 index 0000000000..237dc5683d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.launch +import javax.inject.Inject + +class RetrySendMenuPresenter @Inject constructor( + private val room: MatrixRoom, +) : Presenter<RetrySendMenuState> { + + @Composable + override fun present(): RetrySendMenuState { + val coroutineScope = rememberCoroutineScope() + var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) } + + fun handleEvent(event: RetrySendMenuEvents) { + when (event) { + is RetrySendMenuEvents.EventSelected -> { + selectedEvent = event.event + } + RetrySendMenuEvents.RetrySend -> { + coroutineScope.launch { + selectedEvent?.transactionId?.let { transactionId -> + room.retrySendMessage(transactionId) + } + selectedEvent = null + } + } + RetrySendMenuEvents.RemoveFailed -> { + coroutineScope.launch { + selectedEvent?.transactionId?.let { transactionId -> + room.cancelSend(transactionId) + } + selectedEvent = null + } + } + RetrySendMenuEvents.Dismiss -> { + selectedEvent = null + } + } + } + + return RetrySendMenuState( + selectedEvent = selectedEvent, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt new file mode 100644 index 0000000000..e10e9c752c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +@Immutable +data class RetrySendMenuState( + val selectedEvent: TimelineItem.Event?, + val eventSink: (RetrySendMenuEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt new file mode 100644 index 0000000000..ccb5c26982 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuStateProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +class RetrySendMenuStateProvider : PreviewParameterProvider<RetrySendMenuState> { + override val values: Sequence<RetrySendMenuState> = sequenceOf( + aRetrySendMenuState(event = null), + aRetrySendMenuState(event = aTimelineItemEvent()), + ) +} + +fun aRetrySendMenuState(event: TimelineItem.Event? = aTimelineItemEvent()) = + RetrySendMenuState(selectedEvent = event, eventSink = {}) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt new file mode 100644 index 0000000000..d344a5e995 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMessageMenu.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.retrysendmenu + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import kotlinx.coroutines.launch + +@Composable +internal fun RetrySendMessageMenu( + state: RetrySendMenuState, + modifier: Modifier = Modifier, +) { + val isVisible = state.selectedEvent != null + + fun onDismiss() { + state.eventSink(RetrySendMenuEvents.Dismiss) + } + + fun onRetry() { + state.eventSink(RetrySendMenuEvents.RetrySend) + } + + fun onRemoveFailed() { + state.eventSink(RetrySendMenuEvents.RemoveFailed) + } + + RetrySendMessageMenuBottomSheet( + modifier = modifier, + isVisible = isVisible, + onRetry = ::onRetry, + onRemoveFailed = ::onRemoveFailed, + onDismiss = ::onDismiss + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RetrySendMessageMenuBottomSheet( + isVisible: Boolean, + onRetry: () -> Unit, + onRemoveFailed: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + if (isVisible) { + ModalBottomSheet( + modifier = modifier, +// modifier = modifier.navigationBarsPadding() - FIXME after https://issuetracker.google.com/issues/275849044 +// .imePadding() + sheetState = sheetState, + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + } + } + ) { + RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed) + // FIXME remove after https://issuetracker.google.com/issues/275849044 + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ColumnScope.RetrySendMenuContents( + onRetry: () -> Unit, + onRemoveFailed: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + val coroutineScope = rememberCoroutineScope() + + ListItem(headlineContent = { + Text( + text = stringResource(R.string.screen_room_retry_send_menu_title), + style = ElementTheme.typography.fontBodyLgMedium, + ) + }) + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.screen_room_retry_send_menu_send_again_action), + style = ElementTheme.typography.fontBodyLgRegular, + ) + }, + modifier = Modifier.clickable { + coroutineScope.launch { + sheetState.hide() + onRetry() + } + } + ) + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.screen_room_retry_send_menu_remove_action), + style = ElementTheme.typography.fontBodyLgRegular, + ) + }, + colors = ListItemDefaults.colors(headlineColor = MaterialTheme.colorScheme.error), + modifier = Modifier.clickable { + coroutineScope.launch { + sheetState.hide() + onRemoveFailed() + } + } + ) +} + +@Preview +@Composable +internal fun RetrySendMessageMenuPreviewLight(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) { + ElementPreviewLight { + ContentToPreview(state) + } +} + +@Preview +@Composable +internal fun RetrySendMessageMenuPreviewDark(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) { + ElementPreviewDark { + ContentToPreview(state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview(state: RetrySendMenuState) { + // TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed + Column { + RetrySendMenuContents( + onRetry = {}, + onRemoveFailed = {}, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt new file mode 100644 index 0000000000..055e4bb876 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp) + .clip(MaterialTheme.shapes.small) + .border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small) + .background(ElementTheme.colors.bgInfoSubtle) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Info", + tint = ElementTheme.colors.iconInfoPrimary + ) + Text( + text = stringResource(R.string.screen_room_encrypted_history_banner), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textInfoPrimary + ) + } +} + +@DayNightPreviews +@Composable +internal fun TimelineEncryptedHistoryBannerViewPreview() { + ElementTheme { + TimelineEncryptedHistoryBannerView() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt new file mode 100644 index 0000000000..c76d59806d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModelProvider +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +internal fun TimelineItemDaySeparatorView( + model: TimelineItemDaySeparatorModel, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = model.formattedDate, + style = ElementTheme.typography.fontBodyMdMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Preview +@Composable +internal fun TimelineItemDaySeparatorViewLightPreview( + @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel +) = + ElementPreviewLight { ContentToPreview(model) } + +@Preview +@Composable +internal fun TimelineItemDaySeparatorViewDarkPreview( + @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel +) = + ElementPreviewDark { ContentToPreview(model) } + +@Composable +private fun ContentToPreview(model: TimelineItemDaySeparatorModel) { + TimelineItemDaySeparatorView( + model = model, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemLoadingMoreIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemLoadingMoreIndicator.kt new file mode 100644 index 0000000000..388be95381 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemLoadingMoreIndicator.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator + +@Composable +internal fun TimelineLoadingMoreIndicator(modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + ) + } +} + +@Preview +@Composable +internal fun TimelineLoadingMoreIndicatorLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun TimelineLoadingMoreIndicatorDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + TimelineLoadingMoreIndicator() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt new file mode 100644 index 0000000000..b9e4a75d97 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.debug + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +@ContributesNode(RoomScope::class) +class EventDebugInfoNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val eventId: EventId?, + val timelineItemDebugInfo: TimelineItemDebugInfo, + ) : NodeInputs + + private val inputs = inputs<Inputs>() + + private fun onBackPressed() { + navigateUp() + } + + @Composable + override fun View(modifier: Modifier) = with(inputs) { + EventDebugInfoView( + eventId = eventId, + model = timelineItemDebugInfo.model, + originalJson = timelineItemDebugInfo.originalJson, + latestEditedJson = timelineItemDebugInfo.latestEditedJson, + onBackPressed = ::onBackPressed + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt new file mode 100644 index 0000000000..2daf804359 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.debug + +import android.content.ClipData +import android.content.ClipboardManager +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.theme.ElementTheme + +/** + * Screen used to display debug info for events. + * It will only be available in debug builds. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun EventDebugInfoView( + eventId: EventId?, + model: String, + originalJson: String?, + latestEditedJson: String?, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, + isTest: Boolean = false, +) { + val sectionsInitiallyExpanded = isTest || LocalInspectionMode.current + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "Debug event info", + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) } + ) + }, + modifier = modifier + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(padding) // Window insets + .consumeWindowInsets(padding) + .padding(horizontal = 16.dp) // Internal padding + ) { + item { + Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Event ID:") + CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth()) + } + } + item { + CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded) + } + if (originalJson != null) { + item { + CollapsibleSection(title = "Original JSON:", text = originalJson, initiallyExpanded = sectionsInitiallyExpanded) + } + } + if (latestEditedJson != null) { + item { + CollapsibleSection(title = "Latest edited JSON:", text = latestEditedJson, initiallyExpanded = sectionsInitiallyExpanded) + } + } + } + } +} + +@Composable +private fun CollapsibleSection( + title: String, + text: String, + modifier: Modifier = Modifier, + initiallyExpanded: Boolean = false, +) { + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .clickable { isExpanded = !isExpanded } + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, modifier = Modifier.weight(1f)) + Icon( + imageVector = if (isExpanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, + contentDescription = null + ) + } + AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { + CopyableText(text = text, modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +private fun CopyableText( + text: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) } + Box( + modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(6.dp) + .clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) } + ) { + Text( + text = text, + style = ElementTheme.typography.fontBodyMdRegular.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(8.dp), + ) + } +} + +@Preview +@Composable +internal fun EventDebugInfoViewPreviewLight() { + ElementPreviewLight { + ContentToPreview() + } +} + +@Preview +@Composable +internal fun EventDebugInfoViewPreviewDark() { + ElementPreviewDark { + ContentToPreview() + } +} + +@Composable +private fun ContentToPreview() { + EventDebugInfoView( + eventId = EventId("\$some-event-id"), + model = "Rust(\n\tModel()\n)", + originalJson = "{\"name\": \"original\"}", + latestEditedJson = "{\"name\": \"edited\"}", + onBackPressed = { } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt new file mode 100644 index 0000000000..9aa3ab5e02 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.diff + +import androidx.recyclerview.widget.ListUpdateCallback +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.util.invalidateLast +import timber.log.Timber + +internal class CacheInvalidator(private val itemStatesCache: MutableList<TimelineItem?>) : + ListUpdateCallback { + + override fun onChanged(position: Int, count: Int, payload: Any?) { + Timber.d("onChanged(position= $position, count= $count)") + (position until position + count).forEach { + // Invalidate cache + itemStatesCache[it] = null + } + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + Timber.d("onMoved(fromPosition= $fromPosition, toPosition= $toPosition)") + val model = itemStatesCache.removeAt(fromPosition) + itemStatesCache.add(toPosition, model) + } + + override fun onInserted(position: Int, count: Int) { + Timber.d("onInserted(position= $position, count= $count)") + itemStatesCache.invalidateLast() + repeat(count) { + itemStatesCache.add(position, null) + } + } + + override fun onRemoved(position: Int, count: Int) { + Timber.d("onRemoved(position= $position, count= $count)") + itemStatesCache.invalidateLast() + repeat(count) { + itemStatesCache.removeAt(position) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt new file mode 100644 index 0000000000..4a78447bd7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.diff + +import androidx.recyclerview.widget.DiffUtil +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem + +internal class MatrixTimelineItemsDiffCallback( + private val oldList: List<MatrixTimelineItem>, + private val newList: List<MatrixTimelineItem> +) : DiffUtil.Callback() { + + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList.getOrNull(oldItemPosition) + val newItem = newList.getOrNull(newItemPosition) + return if (oldItem is MatrixTimelineItem.Event && newItem is MatrixTimelineItem.Event) { + oldItem.uniqueId == newItem.uniqueId + } else { + false + } + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList.getOrNull(oldItemPosition) + val newItem = newList.getOrNull(newItemPosition) + return oldItem == newItem + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt new file mode 100644 index 0000000000..aa9786c945 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.recyclerview.widget.DiffUtil +import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator +import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory +import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import kotlin.system.measureTimeMillis + +class TimelineItemsFactory @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val eventItemFactory: TimelineItemEventFactory, + private val virtualItemFactory: TimelineItemVirtualFactory, + private val timelineItemGrouper: TimelineItemGrouper, +) { + private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>()) + private val timelineItemsCache = arrayListOf<TimelineItem?>() + + // Items from rust sdk, used for diffing + private var matrixTimelineItems: List<MatrixTimelineItem> = emptyList() + + private val lock = Mutex() + private val cacheInvalidator = CacheInvalidator(timelineItemsCache) + + @Composable + fun collectItemsAsState(): State<ImmutableList<TimelineItem>> { + return timelineItems.collectAsState() + } + + suspend fun replaceWith( + timelineItems: List<MatrixTimelineItem>, + ) = withContext(dispatchers.computation) { + lock.withLock { + calculateAndApplyDiff(timelineItems) + buildAndEmitTimelineItemStates(timelineItems) + } + } + + private suspend fun buildAndEmitTimelineItemStates(timelineItems: List<MatrixTimelineItem>) { + val newTimelineItemStates = ArrayList<TimelineItem>() + for (index in timelineItemsCache.indices.reversed()) { + val cacheItem = timelineItemsCache[index] + if (cacheItem == null) { + buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> + newTimelineItemStates.add(timelineItemState) + } + } else { + newTimelineItemStates.add(cacheItem) + } + } + val result = timelineItemGrouper.group(newTimelineItemStates).toPersistentList() + this.timelineItems.emit(result) + } + + private fun calculateAndApplyDiff(newTimelineItems: List<MatrixTimelineItem>) { + val timeToDiff = measureTimeMillis { + val diffCallback = + MatrixTimelineItemsDiffCallback( + oldList = matrixTimelineItems, + newList = newTimelineItems + ) + val diffResult = DiffUtil.calculateDiff(diffCallback, false) + matrixTimelineItems = newTimelineItems + diffResult.dispatchUpdatesTo(cacheInvalidator) + } + Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms") + } + + private fun buildAndCacheItem( + timelineItems: List<MatrixTimelineItem>, + index: Int + ): TimelineItem? { + val timelineItemState = + when (val currentTimelineItem = timelineItems[index]) { + is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems) + is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) + MatrixTimelineItem.Other -> null + } + timelineItemsCache[index] = timelineItemState + return timelineItemState + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt new file mode 100644 index 0000000000..eb6d0e45c0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import javax.inject.Inject + +class TimelineItemContentFactory @Inject constructor( + private val messageFactory: TimelineItemContentMessageFactory, + private val redactedMessageFactory: TimelineItemContentRedactedFactory, + private val stickerFactory: TimelineItemContentStickerFactory, + private val utdFactory: TimelineItemContentUTDFactory, + private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory, + private val profileChangeFactory: TimelineItemContentProfileChangeFactory, + private val stateFactory: TimelineItemContentStateFactory, + private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, + private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory +) { + + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + return when (val itemContent = eventTimelineItem.content) { + is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) + is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) + is MessageContent -> messageFactory.create(itemContent) + is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem) + is RedactedContent -> redactedMessageFactory.create(itemContent) + is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) + is StateContent -> stateFactory.create(eventTimelineItem) + is StickerContent -> stickerFactory.create(itemContent) + is UnableToDecryptContent -> utdFactory.create(itemContent) + is UnknownContent -> TimelineItemUnknownContent + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseMessageFactory.kt new file mode 100644 index 0000000000..4dacb76523 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseMessageFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import javax.inject.Inject + +class TimelineItemContentFailedToParseMessageFactory @Inject constructor() { + + fun create(failedToParseMessageLike: FailedToParseMessageLikeContent): TimelineItemEventContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseStateFactory.kt new file mode 100644 index 0000000000..7e2bf90050 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseStateFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import javax.inject.Inject + +class TimelineItemContentFailedToParseStateFactory @Inject constructor() { + + fun create(failedToParseState: FailedToParseStateContent): TimelineItemEventContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt new file mode 100644 index 0000000000..9da31ee6a7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.location.api.Location +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor +import io.element.android.features.messages.impl.timeline.util.toHtmlDocument +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import javax.inject.Inject + +class TimelineItemContentMessageFactory @Inject constructor( + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, +) { + + fun create(content: MessageContent): TimelineItemEventContent { + return when (val messageType = content.type) { + is EmoteMessageType -> TimelineItemEmoteContent( + body = messageType.body, + htmlDocument = messageType.formatted?.toHtmlDocument(), + isEdited = content.isEdited, + ) + is ImageMessageType -> { + val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) + TimelineItemImageContent( + body = messageType.body, + mediaSource = messageType.source, + thumbnailSource = messageType.info?.thumbnailSource, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + blurhash = messageType.info?.blurhash, + width = messageType.info?.width?.toInt(), + height = messageType.info?.height?.toInt(), + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + ) + } + is LocationMessageType -> { + val location = Location.fromGeoUri(messageType.geoUri) + if (location == null) { + TimelineItemTextContent( + body = messageType.body, + htmlDocument = null, + isEdited = content.isEdited, + ) + } else { + TimelineItemLocationContent( + body = messageType.body, + location = location, + description = messageType.description + ) + } + } + is VideoMessageType -> { + val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) + TimelineItemVideoContent( + body = messageType.body, + thumbnailSource = messageType.info?.thumbnailSource, + videoSource = messageType.source, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + width = messageType.info?.width?.toInt(), + height = messageType.info?.height?.toInt(), + duration = messageType.info?.duration?.toMillis() ?: 0L, + blurHash = messageType.info?.blurhash, + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + ) + } + is AudioMessageType -> TimelineItemAudioContent( + body = messageType.body, + audioSource = messageType.source, + duration = messageType.info?.duration?.toMillis() ?: 0L, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + ) + is FileMessageType -> TimelineItemFileContent( + body = messageType.body, + thumbnailSource = messageType.info?.thumbnailSource, + fileSource = messageType.source, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.body) + ) + is NoticeMessageType -> TimelineItemNoticeContent( + body = messageType.body, + htmlDocument = messageType.formatted?.toHtmlDocument(), + isEdited = content.isEdited, + ) + is TextMessageType -> TimelineItemTextContent( + body = messageType.body, + htmlDocument = messageType.formatted?.toHtmlDocument(), + isEdited = content.isEdited, + ) + else -> TimelineItemUnknownContent + } + } + + private fun aspectRatioOf(width: Long?, height: Long?): Float? { + return if (height != null && width != null) { + width.toFloat() / height.toFloat() + } else { + null + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt new file mode 100644 index 0000000000..e54d88326d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import javax.inject.Inject + +class TimelineItemContentProfileChangeFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { + + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemProfileChangeContent(text.orEmpty().toString()) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRedactedFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRedactedFactory.kt new file mode 100644 index 0000000000..dfbbd233c7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRedactedFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import javax.inject.Inject + +class TimelineItemContentRedactedFactory @Inject constructor() { + + fun create(content: RedactedContent): TimelineItemEventContent { + return TimelineItemRedactedContent + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt new file mode 100644 index 0000000000..d5cf0cec2d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import javax.inject.Inject + +class TimelineItemContentRoomMembershipFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { + + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemRoomMembershipContent(text.orEmpty().toString()) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt new file mode 100644 index 0000000000..072b568af9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import javax.inject.Inject + +class TimelineItemContentStateFactory @Inject constructor( + private val timelineEventFormatter: TimelineEventFormatter, +) { + + fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventTimelineItem) + return TimelineItemStateEventContent(text.orEmpty().toString()) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt new file mode 100644 index 0000000000..b823791912 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import javax.inject.Inject + +class TimelineItemContentStickerFactory @Inject constructor() { + + fun create(content: StickerContent): TimelineItemEventContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentUTDFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentUTDFactory.kt new file mode 100644 index 0000000000..3281f6dd9b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentUTDFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import javax.inject.Inject + +class TimelineItemContentUTDFactory @Inject constructor() { + + fun create(content: UnableToDecryptContent): TimelineItemEventContent { + return TimelineItemEncryptedContent(content.data) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt new file mode 100644 index 0000000000..6bc5df1e79 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import kotlinx.collections.immutable.toImmutableList +import java.text.DateFormat +import java.util.Date +import javax.inject.Inject + +class TimelineItemEventFactory @Inject constructor( + private val contentFactory: TimelineItemContentFactory, + private val matrixClient: MatrixClient, +) { + + fun create( + currentTimelineItem: MatrixTimelineItem.Event, + index: Int, + timelineItems: List<MatrixTimelineItem>, + ): TimelineItem.Event { + val currentSender = currentTimelineItem.event.sender + val groupPosition = + computeGroupPosition(currentTimelineItem, timelineItems, index) + val senderDisplayName: String? + val senderAvatarUrl: String? + + when (val senderProfile = currentTimelineItem.event.senderProfile) { + ProfileTimelineDetails.Unavailable, + ProfileTimelineDetails.Pending, + is ProfileTimelineDetails.Error -> { + senderDisplayName = null + senderAvatarUrl = null + } + is ProfileTimelineDetails.Ready -> { + senderDisplayName = senderProfile.displayName + senderAvatarUrl = senderProfile.avatarUrl + } + } + + val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) + val sentTime = timeFormatter.format(Date(currentTimelineItem.event.timestamp)) + + val senderAvatarData = AvatarData( + id = currentSender.value, + name = senderDisplayName ?: currentSender.value, + url = senderAvatarUrl, + size = AvatarSize.TimelineSender + ) + return TimelineItem.Event( + id = currentTimelineItem.uniqueId.toString(), + eventId = currentTimelineItem.eventId, + transactionId = currentTimelineItem.transactionId, + senderId = currentSender, + senderDisplayName = senderDisplayName, + senderAvatar = senderAvatarData, + content = contentFactory.create(currentTimelineItem.event), + isMine = currentTimelineItem.event.isOwn, + sentTime = sentTime, + groupPosition = groupPosition, + reactionsState = currentTimelineItem.computeReactionsState(), + localSendState = currentTimelineItem.event.localSendState, + inReplyTo = currentTimelineItem.event.inReplyTo(), + debugInfo = currentTimelineItem.event.debugInfo, + origin = currentTimelineItem.event.origin, + ) + } + + private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { + val aggregatedReactions = event.reactions.map { + AggregatedReaction( + key = it.key, + count = it.count.toInt(), + isHighlighted = it.senderIds.contains(matrixClient.sessionId), + ) + } + aggregatedReactions.sortedByDescending { it.count } + return TimelineItemReactions(aggregatedReactions.toImmutableList()) + } + + private fun computeGroupPosition( + currentTimelineItem: MatrixTimelineItem.Event, + timelineItems: List<MatrixTimelineItem>, + index: Int + ): TimelineItemGroupPosition { + val prevTimelineItem = + timelineItems.getOrNull(index - 1) as? MatrixTimelineItem.Event + val nextTimelineItem = + timelineItems.getOrNull(index + 1) as? MatrixTimelineItem.Event + val currentSender = currentTimelineItem.event.sender + val previousSender = prevTimelineItem?.event?.sender + val nextSender = nextTimelineItem?.event?.sender + + val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue() + val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue() + + return when { + previousSender != currentSender && nextSender == currentSender -> { + if (nextIsGroupable) { + TimelineItemGroupPosition.First + } else { + TimelineItemGroupPosition.None + } + } + previousSender == currentSender && nextSender == currentSender -> { + if (previousIsGroupable) { + if (nextIsGroupable) { + TimelineItemGroupPosition.Middle + } else { + TimelineItemGroupPosition.Last + } + } else { + if (nextIsGroupable) { + TimelineItemGroupPosition.First + } else { + TimelineItemGroupPosition.None + } + } + } + previousSender == currentSender /* && nextSender != currentSender (== true) */ -> { + if (previousIsGroupable) { + TimelineItemGroupPosition.Last + } else { + TimelineItemGroupPosition.None + } + } + else -> TimelineItemGroupPosition.None + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemDaySeparatorFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemDaySeparatorFactory.kt new file mode 100644 index 0000000000..5a778fe28a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemDaySeparatorFactory.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.virtual + +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel +import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import javax.inject.Inject + +class TimelineItemDaySeparatorFactory @Inject constructor(private val daySeparatorFormatter: DaySeparatorFormatter) { + + fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel { + val formattedDate = daySeparatorFormatter.format(virtualItem.timestamp) + return TimelineItemDaySeparatorModel( + formattedDate = formattedDate + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt new file mode 100644 index 0000000000..6178b1dee7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.factories.virtual + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import javax.inject.Inject + +class TimelineItemVirtualFactory @Inject constructor( + private val daySeparatorFactory: TimelineItemDaySeparatorFactory, +) { + + fun create( + virtualTimelineItem: MatrixTimelineItem.Virtual, + ): TimelineItem.Virtual { + val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) { + "encrypted_history_banner" + } else { + virtualTimelineItem.uniqueId.toString() + } + return TimelineItem.Virtual( + id = id, + model = virtualTimelineItem.computeModel() + ) + } + + private fun MatrixTimelineItem.Virtual.computeModel(): TimelineItemVirtualModel { + return when (val inner = virtual) { + is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner) + is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel + is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt new file mode 100644 index 0000000000..0b8baf692a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.groups + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent + +/** + * Return true if the Event can be grouped in a collapse/expand block + * When [canBeGrouped] returns a value, [canBeDisplayedInBubbleBlock] MUST return the opposite value. + * Since the receiving type are not the same, the two functions exist. + */ +internal fun TimelineItem.Event.canBeGrouped(): Boolean { + return when (content) { + is TimelineItemTextBasedContent, + is TimelineItemEncryptedContent, + is TimelineItemImageContent, + is TimelineItemFileContent, + is TimelineItemVideoContent, + is TimelineItemAudioContent, + is TimelineItemLocationContent, + TimelineItemRedactedContent, + TimelineItemUnknownContent -> false + is TimelineItemProfileChangeContent, + is TimelineItemRoomMembershipContent, + is TimelineItemStateEventContent -> true + } +} + +/** + * Return true if the Event can be grouped in a block of message bubbles. + * When [canBeDisplayedInBubbleBlock] returns a value, [canBeGrouped] MUST return the opposite value. + * Since the receiving type are not the same, the two functions exist. + */ +internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { + return when (event.content) { + is FailedToParseMessageLikeContent, + is MessageContent, + RedactedContent, + is StickerContent, + is UnableToDecryptContent -> true + is FailedToParseStateContent, + is ProfileChangeContent, + is RoomMembershipContent, + UnknownContent, + is StateContent -> false + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt new file mode 100644 index 0000000000..e9e9af6445 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.groups + +import androidx.annotation.VisibleForTesting +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +@SingleIn(RoomScope::class) +class TimelineItemGrouper @Inject constructor() { + + /** + * Keys are identifier of items in a group, only one by group will be kept. + * Values are the actual groupIds. + */ + private val groupIds = HashMap<String, String>() + + /** + * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. + */ + fun group(from: List<TimelineItem>): List<TimelineItem> { + val result = mutableListOf<TimelineItem>() + val currentGroup = mutableListOf<TimelineItem.Event>() + from.forEach { timelineItem -> + if (timelineItem is TimelineItem.Event && timelineItem.canBeGrouped()) { + currentGroup.add(0, timelineItem) + } else { + // timelineItem cannot be grouped + if (currentGroup.isNotEmpty()) { + // There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. + result.addGroup(groupIds, currentGroup) + currentGroup.clear() + } + result.add(timelineItem) + } + } + if (currentGroup.isNotEmpty()) { + result.addGroup(groupIds, currentGroup) + } + return result + } +} + +/** + * Will add a group if there is more than 1 item, else add the item to the list. + */ +private fun MutableList<TimelineItem>.addGroup( + groupIds: MutableMap<String, String>, + groupOfItems: MutableList<TimelineItem.Event> +) { + if (groupOfItems.size == 1) { + // Do not create a group with just 1 item, just add the item to the result + add(groupOfItems.first()) + } else { + val groupId = groupIds.getOrPutGroupId(groupOfItems) + add( + TimelineItem.GroupedEvents( + id = groupId, + events = groupOfItems.toImmutableList() + ) + ) + } +} + +private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String { + assert(timelineItems.isNotEmpty()) + for (item in timelineItems) { + val itemIdentifier = item.identifier() + if (this.contains(itemIdentifier)) { + return this[itemIdentifier]!! + } + } + val timelineItem = timelineItems.first() + return computeGroupIdWith(timelineItem).also { groupId -> + this[timelineItem.identifier()] = groupId + } +} + +@VisibleForTesting +internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group" diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt new file mode 100644 index 0000000000..ba13896c06 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import io.element.android.libraries.core.extensions.ellipsize + +/** + * Length at which we ellipsize a reaction key for display + * + * Reactions can be free text, so we need to limit the length + * displayed on screen. + */ +private const val MAX_DISPLAY_CHARS = 16 + +/** + * @property key the full reaction key (e.g. "👍", "YES!") + * @property count the number of users who reacted with this key + * @property isHighlighted true if the reaction has (also) been sent by the current user. + */ +data class AggregatedReaction( + val key: String, + val count: Int, + val isHighlighted: Boolean = false +) { + + /** + * The key to be displayed on screen. + * + * See [MAX_DISPLAY_CHARS]. + */ + val displayKey: String by lazy { + key.ellipsize(MAX_DISPLAY_CHARS) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt new file mode 100644 index 0000000000..148f565911 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> { + override val values: Sequence<AggregatedReaction> + get() = sequenceOf(false, true).flatMap { + sequenceOf( + anAggregatedReaction(isHighlighted = it), + anAggregatedReaction(isHighlighted = it, count = 88), + ) + } +} + +fun anAggregatedReaction( + key: String = "👍", + count: Int = 1, + isHighlighted: Boolean = false, +) = AggregatedReaction( + key = key, + count = count, + isHighlighted = isHighlighted, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt new file mode 100644 index 0000000000..b1a5c245b9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface TimelineItem { + + fun identifier(): String = when (this) { + is Event -> id + is Virtual -> id + is GroupedEvents -> id + } + + fun contentType(): String = when (this) { + is Event -> content.type + is Virtual -> model.type + is GroupedEvents -> "groupedEvent" + } + + @Immutable + data class Virtual( + val id: String, + val model: TimelineItemVirtualModel + ) : TimelineItem + + @Immutable + data class Event( + val id: String, + val eventId: EventId? = null, + val transactionId: TransactionId? = null, + val senderId: UserId, + val senderDisplayName: String?, + val senderAvatar: AvatarData, + val content: TimelineItemEventContent, + val sentTime: String = "", + val isMine: Boolean = false, + val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, + val reactionsState: TimelineItemReactions, + val localSendState: LocalEventSendState?, + val inReplyTo: InReplyTo?, + val debugInfo: TimelineItemDebugInfo, + val origin: TimelineItemEventOrigin?, + ) : TimelineItem { + + val showSenderInformation = groupPosition.isNew() && !isMine + + val safeSenderName: String = senderDisplayName ?: senderId.value + + val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed + + val isTextMessage: Boolean = content is TimelineItemTextBasedContent + + val isRemote = eventId != null + } + + @Immutable + data class GroupedEvents( + val id: String, + val events: ImmutableList<Event>, + ) : TimelineItem + +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPosition.kt new file mode 100644 index 0000000000..5a93e87e73 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPosition.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import androidx.compose.runtime.Immutable + +/** + * Attribute for a TimelineItem, used to render successive events from the same sender differently. + * + * Possible sequences in the timeline will be: + * + * Only one Event: + * - [None] + * + * Two Events + * - [First] + * - [Last] + * + * Many Events: + * - [First] + * - [Middle] (repeated if necessary) + * - [Last] + */ +@Immutable +sealed interface TimelineItemGroupPosition { + /** + * The event is part of a group of events from the same sender and is the first sent Event. + */ + object First : TimelineItemGroupPosition + + /** + * The event is part of a group of events from the same sender and is neither the first nor the last sent Event. + */ + object Middle : TimelineItemGroupPosition + + /** + * The event is part of a group of events from the same sender and is the last sent Event. + */ + object Last : TimelineItemGroupPosition + + /** + * The event is not part of a group of events. Sender of previous event is different, and sender of next event is different. + */ + object None : TimelineItemGroupPosition + + /** + * Return true if the previous sender of the event is a different sender. + */ + fun isNew(): Boolean = when (this) { + First, None -> true + else -> false + } +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt new file mode 100644 index 0000000000..8d8d1231ec --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPositionProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +internal class TimelineItemGroupPositionProvider : PreviewParameterProvider<TimelineItemGroupPosition> { + override val values = sequenceOf( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Middle, + TimelineItemGroupPosition.Last, + TimelineItemGroupPosition.None, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactions.kt new file mode 100644 index 0000000000..373a8009ec --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactions.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +data class TimelineItemReactions( + val reactions: ImmutableList<AggregatedReaction> +) { + val highlightedKeys: ImmutableList<String> + get() = reactions + .filter { it.isHighlighted } + .map { it.key } + .toPersistentList() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt new file mode 100644 index 0000000000..cbd6373d6a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model + +import kotlinx.collections.immutable.toPersistentList + +fun aTimelineItemReactions() = TimelineItemReactions( + // Use values from AggregatedReactionProvider + reactions = AggregatedReactionProvider().values.toPersistentList() +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleState.kt new file mode 100644 index 0000000000..1912aac668 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.bubble + +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition + +data class BubbleState( + val groupPosition: TimelineItemGroupPosition, + val isMine: Boolean, + val isHighlighted: Boolean, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt new file mode 100644 index 0000000000..fbcbcc5454 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.bubble + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition + +open class BubbleStateProvider : PreviewParameterProvider<BubbleState> { + override val values: Sequence<BubbleState> + get() = sequenceOf( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Middle, + TimelineItemGroupPosition.Last, + TimelineItemGroupPosition.None, + ).map { groupPosition -> + sequenceOf(false, true).map { isMine -> + sequenceOf(false, true).map { isHighlighted -> + BubbleState(groupPosition, isMine = isMine, isHighlighted = isHighlighted) + } + } + .flatten() + } + .flatten() +} + +fun aBubbleState() = BubbleState( + groupPosition = TimelineItemGroupPosition.First, + isMine = false, + isHighlighted = false, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt new file mode 100644 index 0000000000..485b863170 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemAudioContent( + val body: String, + val duration: Long, + val audioSource: MediaSource, + val mimeType: String, + val formattedFileSize: String, + val fileExtension: String, +) : TimelineItemEventContent { + + val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize) + override val type: String = "TimelineItemAudioContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt new file mode 100644 index 0000000000..ed424781f8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaSource + +open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> { + override val values: Sequence<TimelineItemAudioContent> + get() = sequenceOf( + aTimelineItemAudioContent("A sound.mp3"), + aTimelineItemAudioContent("A bigger name sound.mp3"), + aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"), + ) +} + +fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent( + body = fileName, + mimeType = MimeTypes.Pdf, + formattedFileSize = "100kB", + fileExtension = "mp3", + duration = 100, + audioSource = MediaSource(""), +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt new file mode 100644 index 0000000000..27aa26ad80 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import org.jsoup.nodes.Document + +data class TimelineItemEmoteContent( + override val body: String, + override val htmlDocument: Document?, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemEmoteContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt new file mode 100644 index 0000000000..ff1bb36faf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent + +data class TimelineItemEncryptedContent( + val data: UnableToDecryptContent.Data +) : TimelineItemEventContent { + override val type: String = "TimelineItemEncryptedContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt new file mode 100644 index 0000000000..0ff67e481f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface TimelineItemEventContent { + val type: String +} + +/** + * Only text based content and states can be copied. + */ +fun TimelineItemEventContent.canBeCopied(): Boolean = + when (this) { + is TimelineItemTextBasedContent, + is TimelineItemStateContent, + is TimelineItemRedactedContent -> true + else -> false + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt new file mode 100644 index 0000000000..4c25bdfb23 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import org.jsoup.Jsoup + +class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> { + override val values = sequenceOf( + aTimelineItemEmoteContent(), + aTimelineItemEncryptedContent(), + aTimelineItemImageContent(), + aTimelineItemVideoContent(), + aTimelineItemFileContent(), + aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"), + aTimelineItemLocationContent(), + aTimelineItemLocationContent("Location description"), + aTimelineItemNoticeContent(), + aTimelineItemRedactedContent(), + aTimelineItemTextContent(), + aTimelineItemUnknownContent(), + aTimelineItemTextContent().copy(isEdited = true), + ) +} + +class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> { + override val values = sequenceOf( + aTimelineItemEmoteContent(), + aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")), + aTimelineItemNoticeContent(), + aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")), + aTimelineItemTextContent(), + aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")), + ) +} + +fun aTimelineItemEmoteContent() = TimelineItemEmoteContent( + body = "Emote", + htmlDocument = null, + isEdited = false, +) + +fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.Unknown +) + +fun aTimelineItemNoticeContent() = TimelineItemNoticeContent( + body = "Notice", + htmlDocument = null, + isEdited = false, +) + +fun aTimelineItemRedactedContent() = TimelineItemRedactedContent + +fun aTimelineItemTextContent() = TimelineItemTextContent( + body = "Text", + htmlDocument = null, + isEdited = false, +) + +fun aTimelineItemUnknownContent() = TimelineItemUnknownContent + +fun aTimelineItemStateEventContent() = TimelineItemStateEventContent( + body = "A state event", +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt new file mode 100644 index 0000000000..aa35f5a117 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemFileContent( + val body: String, + val fileSource: MediaSource, + val thumbnailSource: MediaSource?, + val formattedFileSize: String, + val fileExtension: String, + val mimeType: String, +) : TimelineItemEventContent { + override val type: String = "TimelineItemFileContent" + + val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt new file mode 100644 index 0000000000..ea3d675ffe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaSource + +open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> { + override val values: Sequence<TimelineItemFileContent> + get() = sequenceOf( + aTimelineItemFileContent(), + aTimelineItemFileContent("A bigger name file.pdf"), + aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"), + ) +} + +fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent( + body = fileName, + thumbnailSource = null, + fileSource = MediaSource(url = ""), + mimeType = MimeTypes.Pdf, + formattedFileSize = "100kB", + fileExtension = "pdf" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt new file mode 100644 index 0000000000..342e0a336b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemImageContent( + val body: String, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val formattedFileSize: String, + val fileExtension: String, + val mimeType: String, + val blurhash: String?, + val width: Int?, + val height: Int?, + val aspectRatio: Float? +) : TimelineItemEventContent { + override val type: String = "TimelineItemImageContent" + + val preferredMediaSource = if (mimeType == MimeTypes.Gif) { + mediaSource + } else { + thumbnailSource ?: mediaSource + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt new file mode 100644 index 0000000000..004bac390a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.media3.common.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaSource + +open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> { + override val values: Sequence<TimelineItemImageContent> + get() = sequenceOf( + aTimelineItemImageContent(), + aTimelineItemImageContent().copy(aspectRatio = 1.0f), + aTimelineItemImageContent().copy(aspectRatio = 1.5f), + ) +} + +fun aTimelineItemImageContent() = TimelineItemImageContent( + body = "a body", + mediaSource = MediaSource(""), + thumbnailSource = null, + mimeType = MimeTypes.IMAGE_JPEG, + blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + width = null, + height = 300, + aspectRatio = 0.5f, + formattedFileSize = "4MB", + fileExtension = "jpg" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt new file mode 100644 index 0000000000..81ff76d3d0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.features.location.api.Location + +data class TimelineItemLocationContent( + val body: String, + val location: Location, + val description: String? = null, +) : TimelineItemEventContent { + override val type: String = "TimelineItemLocationContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt new file mode 100644 index 0000000000..a21f262071 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.api.Location + +open class TimelineItemLocationContentProvider : PreviewParameterProvider<TimelineItemLocationContent> { + override val values: Sequence<TimelineItemLocationContent> + get() = sequenceOf( + aTimelineItemLocationContent(), + aTimelineItemLocationContent("This is a description!"), + ) +} + +fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent( + body = "User location geo:52.2445,0.7186;u=5000", + location = Location( + lat = 52.2445, + lon = 0.7186, + accuracy = 5000f, + ), + description = description, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt new file mode 100644 index 0000000000..175268093e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import org.jsoup.nodes.Document + +data class TimelineItemNoticeContent( + override val body: String, + override val htmlDocument: Document?, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemNoticeContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt new file mode 100644 index 0000000000..7d56394893 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +data class TimelineItemProfileChangeContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemProfileChangeContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt new file mode 100644 index 0000000000..7a8edae953 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +object TimelineItemRedactedContent : TimelineItemEventContent{ + override val type: String = "TimelineItemRedactedContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt new file mode 100644 index 0000000000..93607f01e6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +data class TimelineItemRoomMembershipContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemRoomMembershipContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt new file mode 100644 index 0000000000..b136a602b2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +sealed interface TimelineItemStateContent : TimelineItemEventContent { + val body: String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt new file mode 100644 index 0000000000..1c656c9b96 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +data class TimelineItemStateEventContent( + override val body: String, +) : TimelineItemStateContent { + override val type: String = "TimelineItemStateEventContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt new file mode 100644 index 0000000000..ec6ee16675 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import org.jsoup.nodes.Document + +sealed interface TimelineItemTextBasedContent : TimelineItemEventContent { + val body: String + val htmlDocument: Document? + val isEdited: Boolean +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt new file mode 100644 index 0000000000..ecb50af31c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import org.jsoup.nodes.Document + +data class TimelineItemTextContent( + override val body: String, + override val htmlDocument: Document?, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent{ + override val type: String = "TimelineItemTextContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt new file mode 100644 index 0000000000..44aeb93e3b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +object TimelineItemUnknownContent : TimelineItemEventContent { + override val type: String = "TimelineItemUnknownContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt new file mode 100644 index 0000000000..14ec0a972d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemVideoContent( + val body: String, + val duration: Long, + val videoSource: MediaSource, + val thumbnailSource: MediaSource?, + val aspectRatio: Float?, + val blurHash: String?, + val height: Int?, + val width: Int?, + val mimeType: String, + val formattedFileSize: String, + val fileExtension: String, +) : TimelineItemEventContent { + override val type: String = "TimelineItemImageContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt new file mode 100644 index 0000000000..8310f4a99a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaSource + +open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> { + override val values: Sequence<TimelineItemVideoContent> + get() = sequenceOf( + aTimelineItemVideoContent(), + aTimelineItemVideoContent().copy(aspectRatio = 1.0f), + aTimelineItemVideoContent().copy(aspectRatio = 1.5f), + ) +} + +fun aTimelineItemVideoContent() = TimelineItemVideoContent( + body = "Video.mp4", + thumbnailSource = null, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + aspectRatio = 0.5f, + duration = 100, + videoSource = MediaSource(""), + height = 300, + width = 150, + mimeType = MimeTypes.Mp4, + formattedFileSize = "14MB", + fileExtension = "mp4" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt new file mode 100644 index 0000000000..54e95b7294 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.virtual + +data class TimelineItemDaySeparatorModel( + val formattedDate: String +) : TimelineItemVirtualModel { + override val type: String = "TimelineItemDaySeparatorModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt new file mode 100644 index 0000000000..76a5b72807 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.virtual + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class TimelineItemDaySeparatorModelProvider : PreviewParameterProvider<TimelineItemDaySeparatorModel> { + override val values = sequenceOf( + aTimelineItemDaySeparatorModel("Today"), + aTimelineItemDaySeparatorModel("March 6, 2023") + ) +} + +fun aTimelineItemDaySeparatorModel(formattedDate: String) = TimelineItemDaySeparatorModel( + formattedDate = formattedDate +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt new file mode 100644 index 0000000000..442aed5734 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.virtual + +object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt new file mode 100644 index 0000000000..0b8e3fc0e5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.virtual + +object TimelineItemReadMarkerModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemReadMarkerModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt new file mode 100644 index 0000000000..d6c3529ab4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.model.virtual + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface TimelineItemVirtualModel { + val type: String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileExtensionExtractor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileExtensionExtractor.kt new file mode 100644 index 0000000000..bdb4e0a23d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/FileExtensionExtractor.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.util + +import android.webkit.MimeTypeMap +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +interface FileExtensionExtractor { + fun extractFromName(name: String): String +} + +@ContributesBinding(AppScope::class) +class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor { + override fun extractFromName(name: String): String { + val fileExtension = name.substringAfterLast('.', "") + // Makes sure the extension is known by the system, otherwise default to binary extension. + return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) { + fileExtension + } else { + "bin" + } + } +} + +class FileExtensionExtractorWithoutValidation : FileExtensionExtractor { + override fun extractFromName(name: String): String { + return name.substringAfterLast('.', "") + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt new file mode 100644 index 0000000000..611e270742 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.util + +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +fun Modifier.defaultTimelineContentPadding() = padding(horizontal = 12.dp, vertical = 6.dp) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/MutableListExt.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/MutableListExt.kt new file mode 100644 index 0000000000..50810d3dfc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/MutableListExt.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.util + +internal inline fun <reified T> MutableList<T?>.invalidateLast() { + val indexOfLast = size + if (indexOfLast > 0) { + set(indexOfLast - 1, null) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/toHtmlDocument.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/toHtmlDocument.kt new file mode 100644 index 0000000000..8031e77a4d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/toHtmlDocument.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.timeline.util + +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import org.jsoup.Jsoup +import org.jsoup.nodes.Document + +fun FormattedBody.toHtmlDocument(): Document? { + return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody -> + Jsoup.parse(formattedBody) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt new file mode 100644 index 0000000000..241b1282a0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.utils.messagesummary + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +interface MessageSummaryFormatter { + fun format(event: TimelineItem.Event): String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt new file mode 100644 index 0000000000..42c50bbd9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.impl.utils.messagesummary + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.ui.strings.CommonStrings +import javax.inject.Inject + +@ContributesBinding(RoomScope::class) +class MessageSummaryFormatterImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : MessageSummaryFormatter { + override fun format(event: TimelineItem.Event): String { + return when (event.content) { + is TimelineItemTextBasedContent -> event.content.body + is TimelineItemProfileChangeContent -> event.content.body + is TimelineItemStateContent -> event.content.body + is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) + is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) + is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) + is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) + is TimelineItemImageContent -> context.getString(CommonStrings.common_image) + is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) + is TimelineItemFileContent -> context.getString(CommonStrings.common_file) + is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) + } + } +} diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..8da8200bd5 --- /dev/null +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d změna místnosti"</item> + <item quantity="few">"%1$d změny místnosti"</item> + <item quantity="other">"%1$d změn místnosti"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Fotoaparát"</string> + <string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string> + <string name="screen_room_attachment_source_camera_video">"Natočit video"</string> + <string name="screen_room_attachment_source_files">"Příloha"</string> + <string name="screen_room_attachment_source_gallery">"Knihovna fotografií a videí"</string> + <string name="screen_room_attachment_source_location">"Poloha"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string> + <string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string> + <string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string> + <string name="screen_room_message_copied">"Zpráva zkopírována"</string> + <string name="screen_room_no_permission_to_post">"Nemáte oprávnění zveřejňovat příspěvky v této místnosti"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Odeslat znovu"</string> + <string name="screen_room_retry_send_menu_title">"Vaši zprávu se nepodařilo odeslat"</string> + <string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string> + <string name="screen_room_retry_send_menu_remove_action">"Odstranit"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..1486e35726 --- /dev/null +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d Raumänderung"</item> + <item quantity="other">"%1$d Raumänderungen"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Kamera"</string> + <string name="screen_room_attachment_source_camera_photo">"Foto aufnehmen"</string> + <string name="screen_room_attachment_source_camera_video">"Video aufnehmen"</string> + <string name="screen_room_attachment_source_files">"Anhang"</string> + <string name="screen_room_attachment_source_gallery">"Foto- & Video-Bibliothek"</string> + <string name="screen_room_attachment_source_location">"Standort"</string> + <string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist in diesem Raum derzeit nicht verfügbar"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Benutzerdetails konnten nicht abgerufen werden"</string> + <string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string> + <string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string> + <string name="screen_room_message_copied">"Nachricht kopiert"</string> + <string name="screen_room_no_permission_to_post">"Du bist keine Berechtigung, um in diesem Raum zu posten"</string> + <string name="screen_room_reactions_show_less">"Weniger anzeigen"</string> + <string name="screen_room_reactions_show_more">"Mehr anzeigen"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Erneut senden"</string> + <string name="screen_room_retry_send_menu_title">"Ihre Nachricht konnte nicht gesendet werden"</string> + <string name="screen_room_error_failed_processing_media">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."</string> + <string name="screen_room_retry_send_menu_remove_action">"Entfernen"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..5d41b319bd --- /dev/null +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d cambio en la sala"</item> + <item quantity="other">"%1$d cambios en la sala"</item> + </plurals> + <string name="screen_room_retry_send_menu_remove_action">"Eliminar"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..03dcaffac7 --- /dev/null +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d changement dans la conversation"</item> + <item quantity="other">"%1$d changements dans la conversation"</item> + </plurals> + <plurals name="screen_room_timeline_more_reactions"> + <item quantity="one"></item> + <item quantity="other">"%1$d de plus"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Appareil photo"</string> + <string name="screen_room_attachment_source_camera_photo">"Prendre une photo"</string> + <string name="screen_room_attachment_source_camera_video">"Enregistrer une vidéo"</string> + <string name="screen_room_attachment_source_files">"Pièce-jointe"</string> + <string name="screen_room_attachment_source_gallery">"Gallerie photo et vidéo"</string> + <string name="screen_room_encrypted_history_banner">"L’historique des messages n’est pas disponible actuellement dans ce salon"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Impossible de récupérer les détails de l’utilisateur"</string> + <string name="screen_room_invite_again_alert_message">"Souhaitez-vous les inviter à revenir ?"</string> + <string name="screen_room_invite_again_alert_title">"Vous êtes seul dans ce chat"</string> + <string name="screen_room_message_copied">"Message copié"</string> + <string name="screen_room_no_permission_to_post">"Vous n‘avez pas le droit de poster dans ce salon"</string> + <string name="screen_room_reactions_show_less">"Afficher moins"</string> + <string name="screen_room_reactions_show_more">"Afficher plus"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Renvoyer"</string> + <string name="screen_room_retry_send_menu_title">"Votre message n\'a pas pu être envoyé"</string> + <string name="screen_room_timeline_add_reaction">"Ajouter un emoji"</string> + <string name="screen_room_timeline_less_reactions">"Montrer moins"</string> + <string name="screen_room_error_failed_processing_media">"Échec du traitement du média avant son envoi, veuillez réessayer."</string> + <string name="screen_room_retry_send_menu_remove_action">"Supprimer"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..694de002fe --- /dev/null +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d modifica alla stanza"</item> + <item quantity="other">"%1$d modifiche alla stanza"</item> + </plurals> + <string name="screen_room_retry_send_menu_remove_action">"Rimuovi"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..54774ca172 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d schimbare a camerii"</item> + <item quantity="few">"%1$d schimbări ale camerei"</item> + <item quantity="other">"%1$d schimbări ale camerei"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Cameră foto"</string> + <string name="screen_room_attachment_source_camera_photo">"Faceți o fotografie"</string> + <string name="screen_room_attachment_source_camera_video">"Înregistrați un videoclip"</string> + <string name="screen_room_attachment_source_files">"Atașament"</string> + <string name="screen_room_attachment_source_gallery">"Bibliotecă foto și video"</string> + <string name="screen_room_attachment_source_location">"Locație"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Nu am putut găsi detaliile utilizatorului"</string> + <string name="screen_room_invite_again_alert_message">"Doriți să îi invitați înapoi?"</string> + <string name="screen_room_invite_again_alert_title">"Sunteți singur în această cameră"</string> + <string name="screen_room_message_copied">"Mesaj copiat"</string> + <string name="screen_room_no_permission_to_post">"Nu aveți permisiunea de a posta în această cameră"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Trimiteți din nou"</string> + <string name="screen_room_retry_send_menu_title">"Mesajul dvs. nu a putut fi trimis"</string> + <string name="screen_room_error_failed_processing_media">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string> + <string name="screen_room_retry_send_menu_remove_action">"Ștergeți"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..39b7e974be --- /dev/null +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d zmena miestnosti"</item> + <item quantity="few">"%1$d zmeny miestnosti"</item> + <item quantity="other">"%1$d zmien miestnosti"</item> + </plurals> + <plurals name="screen_room_timeline_more_reactions"> + <item quantity="one"></item> + <item quantity="few">"%1$d ďalšie"</item> + <item quantity="other">"%1$d ďalších"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Kamera"</string> + <string name="screen_room_attachment_source_camera_photo">"Odfotiť"</string> + <string name="screen_room_attachment_source_camera_video">"Nahrať video"</string> + <string name="screen_room_attachment_source_files">"Príloha"</string> + <string name="screen_room_attachment_source_gallery">"Knižnica fotografií a videí"</string> + <string name="screen_room_attachment_source_location">"Poloha"</string> + <string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Nepodarilo sa získať údaje o používateľovi"</string> + <string name="screen_room_invite_again_alert_message">"Chceli by ste ich pozvať späť?"</string> + <string name="screen_room_invite_again_alert_title">"V tomto rozhovore ste sami"</string> + <string name="screen_room_message_copied">"Správa skopírovaná"</string> + <string name="screen_room_no_permission_to_post">"Nemáte povolenie uverejňovať príspevky v tejto miestnosti"</string> + <string name="screen_room_reactions_show_less">"Zobraziť menej"</string> + <string name="screen_room_reactions_show_more">"Zobraziť viac"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string> + <string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string> + <string name="screen_room_timeline_add_reaction">"Pridať emoji"</string> + <string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string> + <string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string> + <string name="screen_room_retry_send_menu_remove_action">"Odstrániť"</string> +</resources> diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..6303589aa2 --- /dev/null +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="room_timeline_state_changes"> + <item quantity="one">"%1$d room change"</item> + <item quantity="other">"%1$d room changes"</item> + </plurals> + <plurals name="screen_room_timeline_more_reactions"> + <item quantity="other">"%1$d more"</item> + </plurals> + <string name="screen_room_attachment_source_camera">"Camera"</string> + <string name="screen_room_attachment_source_camera_photo">"Take photo"</string> + <string name="screen_room_attachment_source_camera_video">"Record a video"</string> + <string name="screen_room_attachment_source_files">"Attachment"</string> + <string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string> + <string name="screen_room_attachment_source_location">"Location"</string> + <string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string> + <string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string> + <string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string> + <string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string> + <string name="screen_room_message_copied">"Message copied"</string> + <string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string> + <string name="screen_room_notification_settings_allow_custom">"Allow custom setting"</string> + <string name="screen_room_notification_settings_allow_custom_footnote">"Turning this on will override your default setting"</string> + <string name="screen_room_notification_settings_custom_settings_title">"Notify me in this chat for"</string> + <string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string> + <string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string> + <string name="screen_room_notification_settings_default_setting_title">"Default setting"</string> + <string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string> + <string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string> + <string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string> + <string name="screen_room_notification_settings_mode_all_messages">"All messages"</string> + <string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string> + <string name="screen_room_reactions_show_less">"Show less"</string> + <string name="screen_room_reactions_show_more">"Show more"</string> + <string name="screen_room_retry_send_menu_send_again_action">"Send again"</string> + <string name="screen_room_retry_send_menu_title">"Your message failed to send"</string> + <string name="screen_room_timeline_add_reaction">"Add emoji"</string> + <string name="screen_room_timeline_less_reactions">"Show less"</string> + <string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string> + <string name="screen_room_retry_send_menu_remove_action">"Remove"</string> +</resources> diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt new file mode 100644 index 0000000000..bb2caa9405 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/FakeMessagesNavigator.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages + +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +class FakeMessagesNavigator : MessagesNavigator { + var onShowEventDebugInfoClickedCount = 0 + private set + + var onForwardEventClickedCount = 0 + private set + + var onReportContentClickedCount = 0 + private set + + override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + onShowEventDebugInfoClickedCount++ + } + + override fun onForwardEventClicked(eventId: EventId) { + onForwardEventClickedCount++ + } + + override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + onReportContentClickedCount++ + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt new file mode 100644 index 0000000000..8e68da1d12 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -0,0 +1,599 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.messages + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.collect.Iterables.skip +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.messages.fixtures.aMessageEvent +import io.element.android.features.messages.fixtures.aTimelineItemsFactory +import io.element.android.features.messages.impl.InviteDialogAction +import io.element.android.features.messages.impl.MessagesEvents +import io.element.android.features.messages.impl.MessagesPresenter +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents +import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter +import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MessagesPresenterTest { + + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `present - initial state`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) + } + } + + @Test + fun `present - handle toggling a reaction`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val room = FakeMatrixRoom() + val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) + + // No crashes when sending a reaction failed + room.givenToggleReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle toggling a reaction twice`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val room = FakeMatrixRoom() + val presenter = createMessagePresenter(matrixRoom = room, coroutineDispatchers = coroutineDispatchers) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(1) + + initialState.eventSink.invoke(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID)) + assertThat(room.myReactions.count()).isEqualTo(0) + } + } + + @Test + fun `present - handle action forward`() = runTest { + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onForwardEventClickedCount).isEqualTo(1) + } + } + + @Test + fun `present - handle action copy`() = runTest { + val clipboardHelper = FakeClipboardHelper() + val event = aMessageEvent() + val presenter = createMessagePresenter(clipboardHelper = clipboardHelper) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, event)) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body) + } + } + + @Test + fun `present - handle action reply`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent())) + + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action reply to an event with no id does nothing`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + // Otherwise we would have some extra items here + ensureAllEventsConsumed() + } + } + + @Test + fun `present - handle action reply to an image media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemImageContent( + body = "image.jpg", + mediaSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = null, + mimeType = MimeTypes.Jpeg, + blurhash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + fileExtension = "jpg", + formattedFileSize = "4MB" + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action reply to a video media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemVideoContent( + body = "video.mp4", + duration = 10L, + videoSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Mp4, + blurHash = null, + width = 20, + height = 20, + aspectRatio = 1.0f, + fileExtension = "mp4", + formattedFileSize = "50MB" + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action reply to a file media message`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemFileContent( + body = "file.pdf", + fileSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + formattedFileSize = "10 MB", + mimeType = MimeTypes.Pdf, + fileExtension = "pdf", + ) + ) + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + val replyMode = finalState.composerState.mode as MessageComposerMode.Reply + assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action edit`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent())) + + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val matrixRoom = FakeMatrixRoom() + val presenter = createMessagePresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) + assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action report content`() = runTest { + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onReportContentClickedCount).isEqualTo(1) + } + } + + @Test + fun `present - handle dismiss action`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.Dismiss) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action show developer info`() = runTest { + val navigator = FakeMessagesNavigator() + val presenter = createMessagePresenter(navigator = navigator) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + assertThat(navigator.onShowEventDebugInfoClickedCount).isEqualTo(1) + } + } + + @Test + fun `present - shows prompt to reinvite users in DM`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 1L) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + + // Initially the composer doesn't have focus, so we don't show the alert + assertThat(initialState.showReinvitePrompt).isFalse() + + // When the input field is focused we show the alert + initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isTrue() + + // If it's dismissed then we stop showing the alert + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) + val dismissedState = awaitItem() + assertThat(dismissedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - doesn't show reinvite prompt in non-direct room`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = false, activeMemberCount = 1L) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + assertThat(initialState.showReinvitePrompt).isFalse() + + initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - doesn't show reinvite prompt if other party is present`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 2L) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + val initialState = awaitItem() + assertThat(initialState.showReinvitePrompt).isFalse() + + initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - handle reinviting other user when memberlist is ready`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID) + room.givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + + val newState = awaitItem() + assertThat(newState.inviteProgress.isSuccess()).isTrue() + assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2) + } + } + + @Test + fun `present - handle reinviting other user when memberlist is error`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID) + room.givenRoomMembersState( + MatrixRoomMembersState.Error( + failure = Throwable(), + prevRoomMembers = listOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + + val newState = awaitItem() + assertThat(newState.inviteProgress.isSuccess()).isTrue() + assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2) + } + } + + @Test + fun `present - handle reinviting other user when memberlist is not ready`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID) + room.givenRoomMembersState(MatrixRoomMembersState.Unknown) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + + val newState = awaitItem() + assertThat(newState.inviteProgress.isFailure()).isTrue() + } + } + + @Test + fun `present - handle reinviting other user when inviting fails`() = runTest { + val room = FakeMatrixRoom(sessionId = A_SESSION_ID) + room.givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + room.givenInviteUserResult(Result.failure(Throwable("Oops!"))) + val presenter = createMessagePresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + + val newState = awaitItem() + assertThat(newState.inviteProgress.isFailure()).isTrue() + } + } + + @Test + fun `present - permission to post`() = runTest { + val matrixRoom = FakeMatrixRoom() + matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(true)) + val presenter = createMessagePresenter(matrixRoom = matrixRoom) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + + assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() + } + } + + @Test + fun `present - no permission to post`() = runTest { + val matrixRoom = FakeMatrixRoom() + matrixRoom.givenCanSendEventResult(MessageEventType.ROOM_MESSAGE, Result.success(false)) + val presenter = createMessagePresenter(matrixRoom = matrixRoom) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Default value + assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() + skipItems(1) + assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() + } + } + + private fun TestScope.createMessagePresenter( + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + matrixRoom: MatrixRoom = FakeMatrixRoom(), + navigator: FakeMessagesNavigator = FakeMessagesNavigator(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + ): MessagesPresenter { + val messageComposerPresenter = MessageComposerPresenter( + appCoroutineScope = this, + room = matrixRoom, + mediaPickerProvider = FakePickerProvider(), + featureFlagService = FakeFeatureFlagService(), + localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), + mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom), + snackbarDispatcher = SnackbarDispatcher(), + analyticsService = FakeAnalyticsService(), + messageComposerContext = MessageComposerContextImpl(), + ) + val timelinePresenter = TimelinePresenter( + timelineItemsFactory = aTimelineItemsFactory(), + room = matrixRoom, + dispatchers = coroutineDispatchers, + appScope = this + ) + val buildMeta = aBuildMeta() + val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) + val customReactionPresenter = CustomReactionPresenter() + val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) + return MessagesPresenter( + room = matrixRoom, + composerPresenter = messageComposerPresenter, + timelinePresenter = timelinePresenter, + actionListPresenter = actionListPresenter, + customReactionPresenter = customReactionPresenter, + retrySendMenuPresenter = retrySendMenuPresenter, + networkMonitor = FakeNetworkMonitor(), + snackbarDispatcher = SnackbarDispatcher(), + messageSummaryFormatter = FakeMessageSummaryFormatter(), + navigator = navigator, + clipboardHelper = clipboardHelper, + dispatchers = coroutineDispatchers, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt new file mode 100644 index 0000000000..0aafa68100 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.actionlist + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.fixtures.aMessageEvent +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ActionListPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for message from me redacted`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Developer, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for message from others redacted`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Developer, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + TimelineItemAction.Developer, + TimelineItemAction.ReportContent, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.Copy, + TimelineItemAction.Developer, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a media item`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = aTimelineItemImageContent(), + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Developer, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a state item in debug build`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val stateEvent = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + stateEvent, + persistentListOf( + TimelineItemAction.Copy, + TimelineItemAction.Developer, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a state item in non-debuggable build`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = false) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val stateEvent = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + stateEvent, + persistentListOf( + TimelineItemAction.Copy, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message in non-debuggable build`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = false) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.Copy, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message with no actions`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = false) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) + ) + val redactedEvent = aMessageEvent( + isMine = true, + content = TimelineItemRedactedContent, + ) + + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) + + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent)) + awaitItem().run { + assertThat(target).isEqualTo(ActionListState.Target.None) + assertThat(displayEmojiReactions).isFalse() + } + } + } + + @Test + fun `present - compute not sent message`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = false) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + eventId = null, // No event id, so it's not sent yet + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), + ) + + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Edit, + TimelineItemAction.Copy, + TimelineItemAction.Redact, + ) + ) + ) + assertThat(successState.displayEmojiReactions).isFalse() + } + } +} + +private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable)) + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt new file mode 100644 index 0000000000..db202569ee --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.attachments + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.fixtures.aLocalMedia +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents +import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter +import io.element.android.features.messages.impl.attachments.preview.SendActionState +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AttachmentsPreviewPresenterTest { + + private val mediaPreProcessor = FakeMediaPreProcessor() + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `present - send media success scenario`() = runTest { + val room = FakeMatrixRoom() + room.givenProgressCallbackValues( + listOf( + Pair(0, 10), + Pair(5, 10), + Pair(10, 10) + ) + ) + val presenter = anAttachmentsPreviewPresenter(room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f)) + val successState = awaitItem() + assertThat(successState.sendActionState).isEqualTo(SendActionState.Done) + assertThat(room.sendMediaCount).isEqualTo(1) + } + } + + @Test + fun `present - send media failure scenario`() = runTest { + val room = FakeMatrixRoom() + val failure = MediaPreProcessor.Failure(null) + room.givenSendMediaResult(Result.failure(failure)) + val presenter = anAttachmentsPreviewPresenter(room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + val loadingState = awaitItem() + assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing) + val failureState = awaitItem() + assertThat(failureState.sendActionState).isEqualTo((SendActionState.Failure(failure))) + assertThat(room.sendMediaCount).isEqualTo(0) + failureState.eventSink(AttachmentsPreviewEvents.ClearSendState) + val clearedState = awaitItem() + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle) + } + } + + private fun anAttachmentsPreviewPresenter( + localMedia: LocalMedia = aLocalMedia( + uri = mockMediaUrl, + ), + room: MatrixRoom = FakeMatrixRoom() + ): AttachmentsPreviewPresenter { + return AttachmentsPreviewPresenter( + attachment = Attachment.Media(localMedia, compressIfPossible = false), + mediaSender = MediaSender(mediaPreProcessor, room) + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt new file mode 100644 index 0000000000..4f1edcb64f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/aMessageEvent.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.fixtures + +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo + +internal fun aMessageEvent( + eventId: EventId? = AN_EVENT_ID, + isMine: Boolean = true, + content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), + inReplyTo: InReplyTo? = null, + debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), + sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), +) = TimelineItem.Event( + id = eventId?.value.orEmpty(), + eventId = eventId, + senderId = A_USER_ID, + senderDisplayName = A_USER_NAME, + senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), + content = content, + sentTime = "", + isMine = isMine, + reactionsState = aTimelineItemReactions(count = 0), + localSendState = sendState, + inReplyTo = inReplyTo, + debugInfo = debugInfo, + origin = null +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt new file mode 100644 index 0000000000..1357c05913 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/media.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.fixtures + +import android.net.Uri +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.media.local.anImageInfo +import io.element.android.libraries.core.mimetype.MimeTypes + +fun aLocalMedia( + uri: Uri, + mediaInfo: MediaInfo = anImageInfo(), +) = LocalMedia( + uri = uri, + info = mediaInfo +) + +fun aMediaAttachment(localMedia: LocalMedia, compressIfPossible: Boolean = true) = Attachment.Media( + localMedia = localMedia, + compressIfPossible = compressIfPossible, +) + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt new file mode 100644 index 0000000000..638c5e0556 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.fixtures + +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseStateFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentMessageFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentProfileChangeFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRedactedFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRoomMembershipFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentStateFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentStickerFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentUTDFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory +import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory +import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope + +internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { + val timelineEventFormatter = aTimelineEventFormatter() + return TimelineItemsFactory( + dispatchers = testCoroutineDispatchers(), + eventItemFactory = TimelineItemEventFactory( + contentFactory = TimelineItemContentFactory( + messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), + redactedMessageFactory = TimelineItemContentRedactedFactory(), + stickerFactory = TimelineItemContentStickerFactory(), + utdFactory = TimelineItemContentUTDFactory(), + roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), + profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), + stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), + failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() + ), + matrixClient = FakeMatrixClient(), + ), + virtualItemFactory = TimelineItemVirtualFactory( + daySeparatorFactory = TimelineItemDaySeparatorFactory( + FakeDaySeparatorFormatter() + ), + ), + timelineItemGrouper = TimelineItemGrouper(), + ) +} + +internal fun aTimelineEventFormatter(): TimelineEventFormatter { + return object : TimelineEventFormatter { + override fun format(event: EventTimelineItem): CharSequence { + return "" + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt new file mode 100644 index 0000000000..502305ab1d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.forward + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.forward.ForwardMessagesEvents +import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ForwardMessagesPresenterTests { + + @Test + fun `present - initial state`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedRooms).isEmpty() + assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(initialState.isSearchActive).isFalse() + assertThat(initialState.isForwarding).isFalse() + assertThat(initialState.error).isNull() + assertThat(initialState.forwardingSucceeded).isNull() + + // Search is run automatically + val searchState = awaitItem() + assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - toggle search active`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isTrue() + + initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - update query`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource().apply { + postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) + } + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val presenter = aPresenter(client = client) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetail()))) + + initialState.eventSink(ForwardMessagesEvents.UpdateQuery("string not contained")) + assertThat(awaitItem().query).isEqualTo("string not contained") + assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - select a room and forward successful`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + awaitItem() + + // Test successful forwarding + initialState.eventSink(ForwardMessagesEvents.ForwardEvent) + + val forwardingState = awaitItem() + assertThat(forwardingState.isSearchActive).isFalse() + assertThat(forwardingState.isForwarding).isTrue() + + val successfulForwardState = awaitItem() + assertThat(successfulForwardState.isForwarding).isFalse() + assertThat(successfulForwardState.forwardingSucceeded).isNotNull() + } + } + + @Test + fun `present - select a room and forward failed, then clear`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(fakeMatrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + awaitItem() + + // Test failed forwarding + room.givenForwardEventResult(Result.failure(Throwable("error"))) + initialState.eventSink(ForwardMessagesEvents.ForwardEvent) + skipItems(1) + + val failedForwardState = awaitItem() + assertThat(failedForwardState.isForwarding).isFalse() + assertThat(failedForwardState.error).isNotNull() + + // Then clear error + initialState.eventSink(ForwardMessagesEvents.ClearError) + assertThat(awaitItem().error).isNull() + } + } + + @Test + fun `present - select and remove a room`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + val summary = aRoomSummaryDetail() + + initialState.eventSink(ForwardMessagesEvents.SetSelectedRoom(summary)) + assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) + + initialState.eventSink(ForwardMessagesEvents.RemoveSelectedRoom) + assertThat(awaitItem().selectedRooms).isEmpty() + } + } + + private fun CoroutineScope.aPresenter( + eventId: EventId = AN_EVENT_ID, + fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(), + coroutineScope: CoroutineScope = this, + client: FakeMatrixClient = FakeMatrixClient(), + ) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client) + +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt new file mode 100644 index 0000000000..5bdef5f9b1 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.media + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaActions +import io.element.android.tests.testutils.simulateLongTask + +class FakeLocalMediaActions : LocalMediaActions { + + var shouldFail = false + + @Composable + override fun Configure() { + //NOOP + } + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun share(localMedia: LocalMedia): Result<Unit> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun open(localMedia: LocalMedia): Result<Unit> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt new file mode 100644 index 0000000000..b5efdaf77d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.media + +import android.net.Uri +import io.element.android.features.messages.fixtures.aLocalMedia +import io.element.android.features.messages.impl.media.local.LocalMedia +import io.element.android.features.messages.impl.media.local.LocalMediaFactory +import io.element.android.features.messages.impl.media.local.MediaInfo +import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor +import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractorWithoutValidation +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaFile + +class FakeLocalMediaFactory( + private val localMediaUri: Uri, + private val fileExtensionExtractor: FileExtensionExtractor = FileExtensionExtractorWithoutValidation() +) : LocalMediaFactory { + + var fallbackMimeType: String = MimeTypes.OctetStream + var fallbackName: String = "File name" + var fallbackFileSize = "0B" + + override fun createFromMediaFile(mediaFile: MediaFile, mediaInfo: MediaInfo): LocalMedia { + return aLocalMedia(uri = localMediaUri, mediaInfo = mediaInfo) + } + + override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { + val safeName = name ?: fallbackName + val mediaInfo = MediaInfo( + name = safeName, + mimeType = mimeType ?: fallbackMimeType, + formattedFileSize = formattedFileSize ?: fallbackFileSize, + fileExtension = fileExtensionExtractor.extractFromName(safeName) + ) + return aLocalMedia(uri, mediaInfo) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt new file mode 100644 index 0000000000..2b66d2cf9b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.media.viewer + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.media.local.aFileInfo +import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents +import io.element.android.features.messages.impl.media.viewer.MediaViewerNode +import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter +import io.element.android.features.messages.media.FakeLocalMediaActions +import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val TESTED_MEDIA_INFO = aFileInfo() + +class MediaViewerPresenterTest { + + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + + @Test + fun `present - download media success scenario`() = runTest { + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + state = awaitItem() + val successData = state.downloadedMedia.dataOrNull() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) + assertThat(successData).isNotNull() + } + } + + @Test + fun `present - check all actions `() = runTest { + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() + val snackbarDispatcher = SnackbarDispatcher() + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + // no state changes while media is loading + state.eventSink(MediaViewerEvents.OpenWith) + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.OpenWith) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() + + // Check failures + mediaActions.shouldFail = true + state.eventSink(MediaViewerEvents.OpenWith) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.Share) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + snackbarDispatcher.clear() + assertThat(awaitItem().snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + } + } + + @Test + fun `present - download media failure then retry with success scenario`() = runTest { + val mediaLoader = FakeMediaLoader() + val mediaActions = FakeLocalMediaActions() + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + mediaLoader.shouldFail = true + val initialState = awaitItem() + assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + val loadingState = awaitItem() + assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) + val failureState = awaitItem() + assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java) + mediaLoader.shouldFail = false + failureState.eventSink(MediaViewerEvents.RetryLoading) + //There is one recomposition because of the retry mechanism + skipItems(1) + val retryLoadingState = awaitItem() + assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + val successData = successState.downloadedMedia.dataOrNull() + assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) + assertThat(successData).isNotNull() + } + } + + private fun aMediaViewerPresenter( + mediaLoader: FakeMediaLoader, + localMediaActions: FakeLocalMediaActions, + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + ): MediaViewerPresenter { + return MediaViewerPresenter( + inputs = MediaViewerNode.Inputs( + mediaInfo = TESTED_MEDIA_INFO, + mediaSource = aMediaSource(), + thumbnailSource = null + ), + localMediaFactory = localMediaFactory, + mediaLoader = mediaLoader, + localMediaActions = localMediaActions, + snackbarDispatcher = snackbarDispatcher, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt new file mode 100644 index 0000000000..090dd36dbe --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/report/ReportMessagePresenterTests.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.report + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.report.ReportMessageEvents +import io.element.android.features.messages.impl.report.ReportMessagePresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ReportMessagePresenterTests { + + @Test + fun `presenter - initial state`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.reason).isEmpty() + assertThat(initialState.blockUser).isFalse() + assertThat(initialState.result).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `presenter - update reason`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val reason = "This user is making the chat very toxic." + initialState.eventSink(ReportMessageEvents.UpdateReason(reason)) + + assertThat(awaitItem().reason).isEqualTo(reason) + } + } + + @Test + fun `presenter - toggle block user`() = runTest { + val presenter = aPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + + assertThat(awaitItem().blockUser).isTrue() + + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + + assertThat(awaitItem().blockUser).isFalse() + } + } + + @Test + fun `presenter - handle successful report and block user`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + skipItems(1) + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + } + } + + @Test + fun `presenter - handle successful report`() = runTest { + val room = FakeMatrixRoom() + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(Async.Success::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + } + } + + @Test + fun `presenter - handle failed report`() = runTest { + val room = FakeMatrixRoom().apply { + givenReportContentResult(Result.failure(Exception("Failed to report content"))) + } + val presenter = aPresenter(matrixRoom = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(Async.Loading::class.java) + val resultState = awaitItem() + assertThat(resultState.result).isInstanceOf(Async.Failure::class.java) + assertThat(room.reportedContentCount).isEqualTo(1) + + resultState.eventSink(ReportMessageEvents.ClearError) + assertThat(awaitItem().result).isInstanceOf(Async.Uninitialized::class.java) + } + } + + private fun aPresenter( + inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID), + matrixRoom: MatrixRoom = FakeMatrixRoom(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + ) = ReportMessagePresenter( + inputs = inputs, + room = matrixRoom, + snackbarDispatcher = snackbarDispatcher, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt new file mode 100644 index 0000000000..d3fda7b881 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -0,0 +1,518 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.textcomposer + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.features.messages.impl.messagecomposer.AttachmentsState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents +import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.media.FakeLocalMediaFactory +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_REPLY +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.textcomposer.MessageComposerMode +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class MessageComposerPresenterTest { + + private val pickerProvider = FakePickerProvider().apply { + givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk + } + private val featureFlagService = FakeFeatureFlagService( + mapOf(FeatureFlags.ShowMediaUploadingFlow.key to true) + ) + private val mediaPreProcessor = FakeMediaPreProcessor() + private val snackbarDispatcher = SnackbarDispatcher() + private val mockMediaUrl: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isFullScreen).isFalse() + assertThat(initialState.text).isEqualTo("") + assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) + assertThat(initialState.showAttachmentSourcePicker).isFalse() + assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None) + assertThat(initialState.isSendButtonVisible).isFalse() + } + } + + @Test + fun `present - toggle fullscreen`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState) + val fullscreenState = awaitItem() + assertThat(fullscreenState.isFullScreen).isTrue() + fullscreenState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState) + val notFullscreenState = awaitItem() + assertThat(notFullscreenState.isFullScreen).isFalse() + } + } + + @Test + fun `present - change message`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(A_MESSAGE) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText("")) + val withEmptyMessageState = awaitItem() + assertThat(withEmptyMessageState.text).isEqualTo("") + assertThat(withEmptyMessageState.isSendButtonVisible).isFalse() + } + } + + @Test + fun `present - change mode to edit`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = anEditMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + state = awaitItem() + assertThat(state.text).isEqualTo(A_MESSAGE) + assertThat(state.isSendButtonVisible).isTrue() + backToNormalMode(state, skipCount = 1) + } + } + + @Test + fun `present - change mode to reply`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = aReplyMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo("") + assertThat(state.isSendButtonVisible).isFalse() + backToNormalMode(state) + } + } + + @Test + fun `present - change mode to quote`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = aQuoteMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo("") + assertThat(state.isSendButtonVisible).isFalse() + backToNormalMode(state) + } + } + + @Test + fun `present - send message`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(A_MESSAGE) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE)) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo("") + assertThat(messageSentState.isSendButtonVisible).isFalse() + } + } + + @Test + fun `present - edit sent message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom() + val presenter = createPresenter( + this, + fakeMatrixRoom, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.text).isEqualTo("") + val mode = anEditMode() + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + skipItems(1) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.text).isEqualTo(A_MESSAGE) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo("") + assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) + } + } + + @Test + fun `present - edit not sent message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom() + val presenter = createPresenter( + this, + fakeMatrixRoom, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.text).isEqualTo("") + val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + skipItems(1) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.text).isEqualTo(A_MESSAGE) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo("") + assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE) + } + } + + @Test + fun `present - reply message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom() + val presenter = createPresenter( + this, + fakeMatrixRoom, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.text).isEqualTo("") + val mode = aReplyMode() + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + val state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo("") + assertThat(state.isSendButtonVisible).isFalse() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(A_REPLY) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY)) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo("") + assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) + } + } + + @Test + fun `present - Open attachments menu`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showAttachmentSourcePicker).isEqualTo(false) + initialState.eventSink(MessageComposerEvents.AddAttachment) + assertThat(awaitItem().showAttachmentSourcePicker).isEqualTo(true) + } + } + + @Test + fun `present - Dismiss attachments menu`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.AddAttachment) + skipItems(1) + + initialState.eventSink(MessageComposerEvents.DismissAttachmentMenu) + assertThat(awaitItem().showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - Pick image from gallery`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(this, room = room) + pickerProvider.givenMimeType(MimeTypes.Images) + mediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.Image( + file = File("/some/path"), + imageInfo = ImageInfo( + width = null, + height = null, + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = File("/some/path") + ) + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) + val previewingState = awaitItem() + assertThat(previewingState.showAttachmentSourcePicker).isFalse() + assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + } + } + + @Test + fun `present - Pick video from gallery`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(this, room = room) + pickerProvider.givenMimeType(MimeTypes.Videos) + mediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.Video( + file = File("/some/path"), + videoInfo = VideoInfo( + width = null, + height = null, + mimetype = null, + duration = null, + size = null, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = File("/some/path") + ) + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) + val previewingState = awaitItem() + assertThat(previewingState.showAttachmentSourcePicker).isFalse() + assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + } + } + + @Test + fun `present - Pick media from gallery & cancel does nothing`() = runTest { + val presenter = createPresenter(this) + with(pickerProvider) { + givenResult(null) // Simulate a user canceling the flow + givenMimeType(MimeTypes.Images) + } + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) + // No crashes here, otherwise it fails + } + } + + @Test + fun `present - Pick file from storage`() = runTest { + val room = FakeMatrixRoom() + room.givenProgressCallbackValues( + listOf( + Pair(0, 10), + Pair(5, 10), + Pair(10, 10) + ) + ) + val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) + val sendingState = awaitItem() + assertThat(sendingState.showAttachmentSourcePicker).isFalse() + assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java) + assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0f)) + assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(0.5f)) + assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.Sending.Uploading(1f)) + val sentState = awaitItem() + assertThat(sentState.attachmentsState).isEqualTo(AttachmentsState.None) + assertThat(room.sendMediaCount).isEqualTo(1) + } + } + + @Test + fun `present - Take photo`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) + val previewingState = awaitItem() + assertThat(previewingState.showAttachmentSourcePicker).isFalse() + assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Record video`() = runTest { + val room = FakeMatrixRoom() + val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) + val previewingState = awaitItem() + assertThat(previewingState.showAttachmentSourcePicker).isFalse() + assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) + } + } + + @Test + fun `present - Uploading media failure can be recovered from`() = runTest { + val room = FakeMatrixRoom().apply { + givenSendMediaResult(Result.failure(Exception())) + } + val presenter = createPresenter(this, room = room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) + val sendingState = awaitItem() + assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending::class.java) + val finalState = awaitItem() + assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java) + snackbarDispatcher.snackbarMessage.test { + // Assert error message received + assertThat(awaitItem()).isNotNull() + } + } + } + + private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) { + state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) + skipItems(skipCount) + val normalState = awaitItem() + assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal("")) + assertThat(normalState.text).isEqualTo("") + assertThat(normalState.isSendButtonVisible).isFalse() + } + + private fun createPresenter( + coroutineScope: CoroutineScope, + room: MatrixRoom = FakeMatrixRoom(), + pickerProvider: PickerProvider = this.pickerProvider, + featureFlagService: FeatureFlagService = this.featureFlagService, + mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, + snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, + ) = MessageComposerPresenter( + coroutineScope, + room, + pickerProvider, + featureFlagService, + localMediaFactory, + MediaSender(mediaPreProcessor, room), + snackbarDispatcher, + FakeAnalyticsService(), + MessageComposerContextImpl(), + ) +} + +fun anEditMode( + eventId: EventId? = AN_EVENT_ID, + message: String = A_MESSAGE, + transactionId: TransactionId? = null, +) = MessageComposerMode.Edit(eventId, message, transactionId) +fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE) +fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt new file mode 100644 index 0000000000..c1d414c633 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.timeline + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.fixtures.aTimelineItemsFactory +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aMessageContent +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.tests.testutils.awaitWithLatch +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TimelinePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createTimelinePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.timelineItems).isEmpty() + val loadedNoTimelineState = awaitItem() + assertThat(loadedNoTimelineState.timelineItems).isEmpty() + } + } + + @Test + fun `present - load more`() = runTest { + val presenter = createTimelinePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.paginationState.hasMoreToLoadBackwards).isTrue() + assertThat(initialState.paginationState.isBackPaginating).isFalse() + initialState.eventSink.invoke(TimelineEvents.LoadMore) + val inPaginationState = awaitItem() + assertThat(inPaginationState.paginationState.isBackPaginating).isTrue() + assertThat(inPaginationState.paginationState.hasMoreToLoadBackwards).isTrue() + val postPaginationState = awaitItem() + assertThat(postPaginationState.paginationState.hasMoreToLoadBackwards).isTrue() + assertThat(postPaginationState.paginationState.isBackPaginating).isFalse() + } + } + + @Test + fun `present - set highlighted event`() = runTest { + val presenter = createTimelinePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + assertThat(initialState.highlightedEventId).isNull() + initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID)) + val withHighlightedState = awaitItem() + assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID) + initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null)) + val withoutHighlightedState = awaitItem() + assertThat(withoutHighlightedState.highlightedEventId).isNull() + } + } + + @Test + fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) + ) + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } + assertThat(timeline.sendReadReceiptCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Event(0, anEventTimelineItem()) + ) + ) + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } + assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { + val timeline = FakeMatrixTimeline( + initialTimelineItems = listOf( + MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker) + ) + ) + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + val initialState = awaitItem() + // Wait for timeline items to be populated + skipItems(1) + awaitWithLatch { latch -> + timeline.sendReadReceiptLatch = latch + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + } + assertThat(timeline.sendReadReceiptCount).isEqualTo(0) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - covers hasNewItems scenarios`() = runTest { + val timeline = FakeMatrixTimeline() + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasNewItems).isFalse() + assertThat(initialState.timelineItems.size).isEqualTo(0) + timeline.updateTimelineItems { + listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(1) + timeline.updateTimelineItems { items -> + items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent()))) + } + skipItems(1) + assertThat(awaitItem().timelineItems.size).isEqualTo(2) + assertThat(awaitItem().hasNewItems).isTrue() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + assertThat(awaitItem().hasNewItems).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createTimelinePresenter( + timeline: MatrixTimeline = FakeMatrixTimeline(), + timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory() + ): TimelinePresenter { + return TimelinePresenter( + timelineItemsFactory = timelineItemsFactory, + room = FakeMatrixRoom(matrixTimeline = timeline), + dispatchers = testCoroutineDispatchers(), + appScope = this + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt new file mode 100644 index 0000000000..237cb81d38 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.timeline.components.customreaction + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CustomReactionPresenterTests { + + private val presenter = CustomReactionPresenter() + + @Test + fun `present - handle selecting and de-selecting an event`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedEventId).isNull() + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(AN_EVENT_ID)) + assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID) + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + assertThat(awaitItem().selectedEventId).isNull() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt new file mode 100644 index 0000000000..1e467b82af --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.timeline.components.retrysendmenu + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents +import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RetrySendMenuPresenterTests { + + private val room = FakeMatrixRoom() + private val presenter = RetrySendMenuPresenter(room) + + @Test + fun `present - handle event selected`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + + assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent) + } + } + + @Test + fun `present - handle dismiss`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.Dismiss) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend with transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend without transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = null) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(0) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle resend with error`() = runTest { + room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error"))) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RetrySend) + assertThat(room.retrySendMessageCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message with transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message without transactionId`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = null) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(0) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + @Test + fun `present - handle remove failed message with error`() = runTest { + room.givenRetrySendMessageResult(Result.failure(IllegalStateException("An error"))) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) + initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) + skipItems(1) + + initialState.eventSink(RetrySendMenuEvents.RemoveFailed) + assertThat(room.cancelSendCount).isEqualTo(1) + assertThat(awaitItem().selectedEvent).isNull() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt new file mode 100644 index 0000000000..d5ce31f87a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/groups/TimelineItemGrouperTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.timeline.groups + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper +import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class TimelineItemGrouperTest { + private val sut = TimelineItemGrouper() + + private val aGroupableItem = TimelineItem.Event( + id = "0", + senderId = A_USER_ID, + senderAvatar = anAvatarData(), + senderDisplayName = "", + content = TimelineItemStateEventContent(body = "a state event"), + reactionsState = aTimelineItemReactions(count = 0), + localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + inReplyTo = null, + debugInfo = aTimelineItemDebugInfo(), + origin = null + ) + private val aNonGroupableItem = aMessageEvent() + private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) + + @Test + fun `test empty`() { + val result = sut.group(emptyList()) + assertThat(result).isEmpty() + } + + @Test + fun `test non groupables`() { + val result = sut.group( + listOf( + aNonGroupableItem, + aNonGroupableItem, + ), + ) + assertThat(result).isEqualTo( + listOf( + aNonGroupableItem, + aNonGroupableItem, + ) + ) + } + + @Test + fun `test groupables and ensure reordering`() { + val result = sut.group( + listOf( + aGroupableItem.copy(id = "1"), + aGroupableItem.copy(id = "0"), + ), + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem.copy("0"), + aGroupableItem.copy(id = "1"), + ).toImmutableList() + ), + ) + ) + } + + @Test + fun `test 1 groupable, not group must be created`() { + val listsToTest = listOf( + listOf(aGroupableItem), + listOf(aGroupableItem, aNonGroupableItem), + listOf(aGroupableItem, aNonGroupableItemNoEvent), + listOf(aNonGroupableItem, aGroupableItem), + listOf(aNonGroupableItemNoEvent, aGroupableItem), + listOf(aNonGroupableItem, aGroupableItem, aNonGroupableItem), + listOf(aNonGroupableItemNoEvent, aGroupableItem, aNonGroupableItemNoEvent), + listOf(aGroupableItem, aNonGroupableItem, aGroupableItem), + listOf(aGroupableItem, aNonGroupableItemNoEvent, aGroupableItem), + listOf(aNonGroupableItem), + listOf(aNonGroupableItemNoEvent), + ) + listsToTest.forEach { listToTest -> + val result = sut.group(listToTest) + assertThat(result).isEqualTo(listToTest) + } + } + + @Test + fun `test 3 blocks`() { + val result = sut.group( + listOf( + aGroupableItem, + aGroupableItem, + aNonGroupableItem, + aGroupableItem, + aGroupableItem, + aGroupableItem, + ), + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem, + aGroupableItem, + ).toImmutableList() + ), + aNonGroupableItem, + TimelineItem.GroupedEvents( + computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem, + aGroupableItem, + aGroupableItem, + ).toImmutableList() + ) + ) + ) + } + + @Test + fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() { + // When + val groupableItems = mutableListOf( + aGroupableItem.copy(id = "1"), + aGroupableItem.copy(id = "2") + ) + val expectedGroupId = sut.group(groupableItems).first().identifier() + groupableItems.add(0, aGroupableItem.copy("3")) + groupableItems.add(2, aGroupableItem.copy("4")) + groupableItems.add(aGroupableItem.copy("5")) + val actualGroupId = sut.group(groupableItems).first().identifier() + // Then + assertThat(actualGroupId).isEqualTo(expectedGroupId) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt new file mode 100644 index 0000000000..0e1ccbd003 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.timeline.model + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import org.junit.Assert.assertEquals +import org.junit.Test + +class AggregatedReactionTest { + @Test + fun `reaction display key is shortened`() { + val reaction = AggregatedReaction( + key = "1234567890123456790", + count = 1, + isHighlighted = false + ) + + assertEquals("1234567890123456…", reaction.displayKey) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt new file mode 100644 index 0000000000..3f8205e555 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/utils/messagesummary/FakeMessageSummaryFormatter.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.utils.messagesummary + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter + +class FakeMessageSummaryFormatter : MessageSummaryFormatter { + + private var result = "A message" + + override fun format(event: TimelineItem.Event): String = result + + fun givenMessageResult(value: String) { + result = value + } +} diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts new file mode 100644 index 0000000000..27360e9567 --- /dev/null +++ b/features/messages/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.messages.test" +} + +dependencies { + api(projects.features.messages.api) +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt new file mode 100644 index 0000000000..75c992f495 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.messages.test + +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.textcomposer.MessageComposerMode + +class MessageComposerContextFake( + override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null) +) : MessageComposerContext diff --git a/features/networkmonitor/api/build.gradle.kts b/features/networkmonitor/api/build.gradle.kts new file mode 100644 index 0000000000..a7fe58285c --- /dev/null +++ b/features/networkmonitor/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.networkmonitor.api" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) +} diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt new file mode 100644 index 0000000000..9e217adb41 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.networkmonitor.api + +import kotlinx.coroutines.flow.StateFlow + +interface NetworkMonitor { + val connectivity: StateFlow<NetworkStatus> +} diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkStatus.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkStatus.kt new file mode 100644 index 0000000000..4a2f012384 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkStatus.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.networkmonitor.api + +enum class NetworkStatus { + Online, + Offline +} diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt new file mode 100644 index 0000000000..7d01d668c9 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.networkmonitor.api.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.WifiOff +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ConnectivityIndicatorView( + isOnline: Boolean, + modifier: Modifier = Modifier +) { + val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline } + val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline } + + // Display the network indicator with an animation + AnimatedVisibility( + visibleState = isIndicatorVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Indicator(modifier) + } + + // Show missing status bar padding when the indicator is not visible + AnimatedVisibility( + visibleState = isStatusBarPaddingVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + StatusBarPaddingSpacer(modifier) + } +} + +@Composable +private fun Indicator(modifier: Modifier = Modifier) { + Row( + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer) + .statusBarsPadding() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Bottom, + ) { + val tint = MaterialTheme.colorScheme.primary + Image( + imageVector = Icons.Outlined.WifiOff, + contentDescription = null, + colorFilter = ColorFilter.tint(tint), + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(CommonStrings.common_offline), + style = ElementTheme.typography.fontBodyMdMedium, + color = tint, + ) + } +} + +@Composable +private fun StatusBarPaddingSpacer(modifier: Modifier = Modifier) { + Spacer(modifier = modifier.statusBarsPadding()) +} + +@Preview +@Composable +internal fun PreviewLightConnectivityIndicatorView() { + ElementPreviewLight { + ConnectivityIndicatorView(isOnline = false) + } +} + +@Preview +@Composable +internal fun PreviewDarkConnectivityIndicatorView() { + ElementPreviewDark { + ConnectivityIndicatorView(isOnline = false) + } +} diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts new file mode 100644 index 0000000000..6db3f38d32 --- /dev/null +++ b/features/networkmonitor/impl/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.features.networkmonitor.impl" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.features.networkmonitor.api) +} diff --git a/features/networkmonitor/impl/src/main/AndroidManifest.xml b/features/networkmonitor/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c168cfccc7 --- /dev/null +++ b/features/networkmonitor/impl/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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.ACCESS_NETWORK_STATE" /> +</manifest> diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt new file mode 100644 index 0000000000..ce881163f1 --- /dev/null +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(FlowPreview::class) + +package io.element.android.features.networkmonitor.impl + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +@ContributesBinding(scope = AppScope::class) +@SingleIn(AppScope::class) +class NetworkMonitorImpl @Inject constructor( + @ApplicationContext context: Context, + appCoroutineScope: CoroutineScope, +) : NetworkMonitor { + + private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) + + override val connectivity: StateFlow<NetworkStatus> = callbackFlow { + + /** + * Calling connectivityManager methods synchronously from the callbacks is not safe. + * So instead we just keep the count of active networks, ie. those checking the capability request. + * Debounce the result to avoid quick offline<->online changes. + */ + val callback = object : ConnectivityManager.NetworkCallback() { + + private val activeNetworksCount = AtomicInteger(0) + + override fun onLost(network: Network) { + if (activeNetworksCount.decrementAndGet() == 0) { + trySendBlocking(NetworkStatus.Offline) + } + } + + override fun onAvailable(network: Network) { + if (activeNetworksCount.incrementAndGet() > 0) { + trySendBlocking(NetworkStatus.Online) + } + } + } + trySendBlocking(connectivityManager.activeNetworkStatus()) + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + Timber.d("Subscribe") + awaitClose { + Timber.d("Unsubscribe") + connectivityManager.unregisterNetworkCallback(callback) + } + } + .distinctUntilChanged() + .debounce(300) + .onEach { + Timber.d("NetworkStatus changed=$it") + } + .stateIn(appCoroutineScope, SharingStarted.WhileSubscribed(), connectivityManager.activeNetworkStatus()) + + private fun ConnectivityManager.activeNetworkStatus(): NetworkStatus { + return activeNetwork?.let { + getNetworkCapabilities(it)?.getNetworkStatus() + } ?: NetworkStatus.Offline + } + + private fun NetworkCapabilities.getNetworkStatus(): NetworkStatus { + val hasInternet = hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + return if (hasInternet) { + NetworkStatus.Online + } else { + NetworkStatus.Offline + } + } +} diff --git a/features/networkmonitor/test/build.gradle.kts b/features/networkmonitor/test/build.gradle.kts new file mode 100644 index 0000000000..e9fd5d5773 --- /dev/null +++ b/features/networkmonitor/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.networkmonitor.test" +} + +dependencies { + api(projects.features.networkmonitor.api) + api(libs.coroutines.core) +} diff --git a/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt b/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt new file mode 100644 index 0000000000..7db4acaa32 --- /dev/null +++ b/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.networkmonitor.test + +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeNetworkMonitor(initialStatus: NetworkStatus = NetworkStatus.Online) : NetworkMonitor { + override val connectivity = MutableStateFlow(initialStatus) +} diff --git a/features/onboarding/api/build.gradle.kts b/features/onboarding/api/build.gradle.kts new file mode 100644 index 0000000000..cb43796fa2 --- /dev/null +++ b/features/onboarding/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.onboarding.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt new file mode 100644 index 0000000000..7be45ce236 --- /dev/null +++ b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface OnBoardingEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onSignUp() + fun onSignIn() + } +} diff --git a/features/onboarding/impl/build.gradle.kts b/features/onboarding/impl/build.gradle.kts new file mode 100644 index 0000000000..0952835d46 --- /dev/null +++ b/features/onboarding/impl/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.onboarding.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + api(projects.features.onboarding.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/DefaultOnBoardingEntryPoint.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/DefaultOnBoardingEntryPoint.kt new file mode 100644 index 0000000000..b5ce63116d --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/DefaultOnBoardingEntryPoint.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.onboarding.api.OnBoardingEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultOnBoardingEntryPoint @Inject constructor() : OnBoardingEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): OnBoardingEntryPoint.NodeBuilder { + return object : OnBoardingEntryPoint.NodeBuilder { + + val plugins = ArrayList<Plugin>() + + override fun callback(callback: OnBoardingEntryPoint.Callback): OnBoardingEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<OnBoardingNode>(buildContext, plugins) + } + } + } +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt new file mode 100644 index 0000000000..de164386b3 --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +object OnBoardingConfig { + const val canLoginWithQrCode = false + const val canCreateAccount = false +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt new file mode 100644 index 0000000000..d86623cae2 --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.onboarding.api.OnBoardingEntryPoint +import io.element.android.libraries.di.AppScope + +@ContributesNode(AppScope::class) +class OnBoardingNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: OnBoardingPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + + private fun onSignIn() { + plugins<OnBoardingEntryPoint.Callback>().forEach { it.onSignIn() } + } + + private fun onSignUp() { + plugins<OnBoardingEntryPoint.Callback>().forEach { it.onSignUp() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + OnBoardingView( + state = state, + modifier = modifier, + onSignIn = ::onSignIn, + onCreateAccount = ::onSignUp, + ) + } +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt new file mode 100644 index 0000000000..48a360e6c9 --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +/** + * Note: this Presenter is ignored regarding code coverage because it cannot reach the coverage threshold. + * When this presenter get more code in it, please remove the ignore rule in the kover configuration. + */ +class OnBoardingPresenter @Inject constructor( +) : Presenter<OnBoardingState> { + @Composable + override fun present(): OnBoardingState { + return OnBoardingState( + canLoginWithQrCode = OnBoardingConfig.canLoginWithQrCode, + canCreateAccount = OnBoardingConfig.canCreateAccount, + ) + } +} diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt new file mode 100644 index 0000000000..88215c0c1e --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +data class OnBoardingState( + val canLoginWithQrCode: Boolean, + val canCreateAccount: Boolean, +) diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt new file mode 100644 index 0000000000..1c60a56018 --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingStateProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class OnBoardingStateProvider : PreviewParameterProvider<OnBoardingState> { + override val values: Sequence<OnBoardingState> + get() = sequenceOf( + anOnBoardingState(), + anOnBoardingState(canLoginWithQrCode = true), + anOnBoardingState(canCreateAccount = true), + anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true), + ) +} + +fun anOnBoardingState( + canLoginWithQrCode: Boolean = false, + canCreateAccount: Boolean = false +) = OnBoardingState( + canLoginWithQrCode = canLoginWithQrCode, + canCreateAccount = canCreateAccount +) diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt new file mode 100644 index 0000000000..0651d9cc2e --- /dev/null +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom +import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +// Refs: +// FTUE: +// - https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0 +// ElementX: +// - https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=1816-97419 +@Composable +fun OnBoardingView( + state: OnBoardingState, + modifier: Modifier = Modifier, + onSignInWithQrCode: () -> Unit = {}, + onSignIn: () -> Unit = {}, + onCreateAccount: () -> Unit = {}, +) { + OnBoardingPage( + modifier = modifier, + content = { + OnBoardingContent() + }, + footer = { + OnBoardingButtons( + state = state, + onSignInWithQrCode = onSignInWithQrCode, + onSignIn = onSignIn, + onCreateAccount = onCreateAccount, + ) + } + ) +} + +@Composable +private fun OnBoardingContent(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + ElementLogoAtom( + size = ElementLogoAtomSize.Large, + modifier = Modifier.padding(top = ElementLogoAtomSize.Large.shadowRadius / 2) + ) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = 0.6f + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = CenterHorizontally, + ) { + Text( + text = stringResource(id = R.string.screen_onboarding_welcome_title), + color = ElementTheme.materialColors.primary, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.screen_onboarding_welcome_message), + color = ElementTheme.materialColors.secondary, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 17.sp), + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun OnBoardingButtons( + state: OnBoardingState, + onSignInWithQrCode: () -> Unit, + onSignIn: () -> Unit, + onCreateAccount: () -> Unit, + modifier: Modifier = Modifier, +) { + ButtonColumnMolecule(modifier = modifier) { + val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) { + R.string.screen_onboarding_sign_in_manually + } else { + CommonStrings.action_continue + } + if (state.canLoginWithQrCode) { + Button( + onClick = onSignInWithQrCode, + enabled = true, + modifier = Modifier + .fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.QrCode, contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(14.dp)) + Text( + text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + Button( + onClick = onSignIn, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.onBoardingSignIn) + ) { + Text( + text = stringResource(id = signInButtonStringRes), + style = ElementTheme.typography.aliasButtonText, + ) + } + if (state.canCreateAccount) { + OutlinedButton( + onClick = onCreateAccount, + enabled = true, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.screen_onboarding_sign_up), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@DayNightPreviews +@Composable +internal fun OnBoardingScreenPreview( + @PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState +) = ElementPreview { + OnBoardingView(state) +} diff --git a/features/onboarding/impl/src/main/res/values-cs/translations.xml b/features/onboarding/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..6b8f0eaa91 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Ruční přihlášení"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Přihlásit se pomocí QR kódu"</string> + <string name="screen_onboarding_sign_up">"Vytvořit účet"</string> + <string name="screen_onboarding_subtitle">"Komunikujte a spolupracujte bezpečně"</string> + <string name="screen_onboarding_welcome_message">"Vítejte u dosud nejrychlejšího Elementu. Vylepšený pro rychlost a jednoduchost."</string> + <string name="screen_onboarding_welcome_subtitle">"Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost."</string> + <string name="screen_onboarding_welcome_title">"Buďte ve svém živlu"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..e36fa31a2b --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string> + <string name="screen_onboarding_sign_up">"Konto erstellen"</string> + <string name="screen_onboarding_subtitle">"Sicher kommunizieren und zusammenarbeiten"</string> + <string name="screen_onboarding_welcome_message">"Willkommen beim schnellsten Element jemals. Optimiert für Geschwindigkeit und Einfachheit."</string> + <string name="screen_onboarding_welcome_subtitle">"Willkommen zur %1$s. Verbessert, für Geschwindigkeit und Einfachheit."</string> + <string name="screen_onboarding_welcome_title">"Sei in deinem Element"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-es/translations.xml b/features/onboarding/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..2489344438 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_welcome_subtitle">"Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez."</string> + <string name="screen_onboarding_welcome_title">"Siéntente en tu Elemento"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..5ed96ebf60 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Se connecter manuellement"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Se connecter avec un code QR"</string> + <string name="screen_onboarding_sign_up">"Créer un compte"</string> + <string name="screen_onboarding_subtitle">"Communiquer et collaborer en toute sécurité"</string> + <string name="screen_onboarding_welcome_message">"Bienvenue dans l’Element le plus rapide de tous les temps. Surpuissant pour plus de vitesse et de simplicité."</string> + <string name="screen_onboarding_welcome_subtitle">"Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité."</string> + <string name="screen_onboarding_welcome_title">"Soyez dans votre Element"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-it/translations.xml b/features/onboarding/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..652d9e6c22 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_welcome_subtitle">"Benvenuto su %1$s. Potenziato in velocità e semplicità."</string> + <string name="screen_onboarding_welcome_title">"Sii nel tuo elemento"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-ro/translations.xml b/features/onboarding/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..3572d3a47f --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Conectați-vă manual"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Conectați-vă cu un cod QR"</string> + <string name="screen_onboarding_sign_up">"Creați un cont"</string> + <string name="screen_onboarding_subtitle">"Comunicați și colaborați în siguranță"</string> + <string name="screen_onboarding_welcome_message">"Bine ați venit la cel mai rapid Element din toate timpurile. Supraalimentat pentru viteză și simplitate."</string> + <string name="screen_onboarding_welcome_subtitle">"Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."</string> + <string name="screen_onboarding_welcome_title">"Fii în Elementul tău"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values-sk/translations.xml b/features/onboarding/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..b41e671956 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Prihlásiť sa manuálne"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Prihlásiť sa pomocou QR kódu"</string> + <string name="screen_onboarding_sign_up">"Vytvoriť účet"</string> + <string name="screen_onboarding_subtitle">"Komunikujte a spolupracujte bezpečne"</string> + <string name="screen_onboarding_welcome_message">"Vitajte v najrýchlejšom Element vôbec. Nadupaný pre rýchlosť a jednoduchosť."</string> + <string name="screen_onboarding_welcome_subtitle">"Vitajte v %1$s. Nadupaný, pre rýchlosť a jednoduchosť."</string> + <string name="screen_onboarding_welcome_title">"Buďte vo svojom elemente"</string> +</resources> diff --git a/features/onboarding/impl/src/main/res/values/localazy.xml b/features/onboarding/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..cdb258cdad --- /dev/null +++ b/features/onboarding/impl/src/main/res/values/localazy.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_onboarding_sign_in_manually">"Sign in manually"</string> + <string name="screen_onboarding_sign_in_with_qr_code">"Sign in with QR code"</string> + <string name="screen_onboarding_sign_up">"Create account"</string> + <string name="screen_onboarding_subtitle">"Communicate and collaborate securely"</string> + <string name="screen_onboarding_welcome_message">"Welcome to the fastest Element ever. Supercharged for speed and simplicity."</string> + <string name="screen_onboarding_welcome_subtitle">"Welcome to %1$s. Supercharged, for speed and simplicity."</string> + <string name="screen_onboarding_welcome_title">"Be in your element"</string> +</resources> diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt new file mode 100644 index 0000000000..f415cd795f --- /dev/null +++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.onboarding.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class OnBoardingPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = OnBoardingPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithQrCode).isFalse() + assertThat(initialState.canCreateAccount).isFalse() + } + } +} diff --git a/features/preferences/api/build.gradle.kts b/features/preferences/api/build.gradle.kts new file mode 100644 index 0000000000..c20fe9aabb --- /dev/null +++ b/features/preferences/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.preferences.api" +} + +dependencies { + implementation(projects.libraries.architecture) + api(projects.libraries.matrix.api) +} diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt new file mode 100644 index 0000000000..191535a3a0 --- /dev/null +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.api + +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow + +interface CacheService { + /** + * A flow of [SessionId], can let the app to know when the + * cache has been cleared for a given session, for instance to restart the app. + */ + val clearedCacheEventFlow: Flow<SessionId> +} diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt new file mode 100644 index 0000000000..3d1a516593 --- /dev/null +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface PreferencesEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onOpenBugReport() + fun onVerifyClicked() + } +} diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts new file mode 100644 index 0000000000..f183f7f1fa --- /dev/null +++ b/features/preferences/impl/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.preferences.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.featureflag.ui) + implementation(projects.libraries.network) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.features.rageshake.api) + implementation(projects.features.analytics.api) + implementation(projects.features.ftue.api) + implementation(projects.libraries.matrixui) + implementation(projects.features.logout.api) + implementation(projects.services.toolbox.api) + implementation(libs.datetime) + implementation(libs.accompanist.placeholder) + implementation(libs.coil.compose) + implementation(libs.androidx.browser) + api(projects.features.preferences.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.rageshake.impl) + testImplementation(projects.features.logout.impl) + testImplementation(projects.features.analytics.test) + testImplementation(projects.features.analytics.impl) + testImplementation(projects.tests.testutils) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt new file mode 100644 index 0000000000..2ffe480518 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultCacheService @Inject constructor() : CacheService { + + private val _clearedCacheEventFlow = MutableSharedFlow<SessionId>(0) + override val clearedCacheEventFlow: Flow<SessionId> = _clearedCacheEventFlow + + suspend fun onClearedCache(sessionId: SessionId) { + _clearedCacheEventFlow.emit(sessionId) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt new file mode 100644 index 0000000000..aa286394dc --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PreferencesEntryPoint.NodeBuilder { + return object : PreferencesEntryPoint.NodeBuilder { + val plugins = ArrayList<Plugin>() + + override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<PreferencesFlowNode>(buildContext, plugins) + } + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt new file mode 100644 index 0000000000..e5b8254488 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.preferences.impl.about.AboutNode +import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode +import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode +import io.element.android.features.preferences.impl.root.PreferencesRootNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class PreferencesFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : BackstackNode<PreferencesFlowNode.NavTarget>( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + + @Parcelize + object DeveloperSettings : NavTarget + + @Parcelize + object AnalyticsSettings : NavTarget + + @Parcelize + object About : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : PreferencesRootNode.Callback { + override fun onOpenBugReport() { + plugins<PreferencesEntryPoint.Callback>().forEach { it.onOpenBugReport() } + } + + override fun onVerifyClicked() { + plugins<PreferencesEntryPoint.Callback>().forEach { it.onVerifyClicked() } + } + + override fun onOpenAnalytics() { + backstack.push(NavTarget.AnalyticsSettings) + } + + override fun onOpenAbout() { + backstack.push(NavTarget.About) + } + + override fun onOpenDeveloperSettings() { + backstack.push(NavTarget.DeveloperSettings) + } + } + createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback)) + } + NavTarget.DeveloperSettings -> { + createNode<DeveloperSettingsNode>(buildContext) + } + NavTarget.About -> { + createNode<AboutNode>(buildContext) + } + NavTarget.AnalyticsSettings -> { + createNode<AnalyticsSettingsNode>(buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler() + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt new file mode 100644 index 0000000000..000a397cfe --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.theme.ElementTheme + +@ContributesNode(SessionScope::class) +class AboutNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: AboutPresenter, +) : Node(buildContext, plugins = plugins) { + + private fun onElementLegalClicked( + activity: Activity, + darkTheme: Boolean, + elementLegal: ElementLegal, + ) { + activity.openUrlInChromeCustomTab(null, darkTheme, elementLegal.url) + } + + @Composable + override fun View(modifier: Modifier) { + val activity = LocalContext.current as Activity + val isDark = ElementTheme.isLightTheme.not() + val state = presenter.present() + AboutView( + state = state, + onBackPressed = ::navigateUp, + onElementLegalClicked = { elementLegal -> + onElementLegalClicked(activity, isDark, elementLegal) + }, + modifier = modifier + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt new file mode 100644 index 0000000000..556c62ec73 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import androidx.compose.runtime.Composable +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +class AboutPresenter @Inject constructor() : Presenter<AboutState> { + + @Composable + override fun present(): AboutState { + + return AboutState( + elementLegals = getAllLegals(), + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt new file mode 100644 index 0000000000..cd361bd4b0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +// Do not use default value, so no member get forgotten in the presenters. +data class AboutState( + val elementLegals: List<ElementLegal>, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt new file mode 100644 index 0000000000..5775cbe48c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AboutStateProvider : PreviewParameterProvider<AboutState> { + override val values: Sequence<AboutState> + get() = sequenceOf( + aAboutState(), + ) +} + +fun aAboutState() = AboutState( + elementLegals = getAllLegals(), +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt new file mode 100644 index 0000000000..b7c2663b72 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AboutView( + state: AboutState, + onElementLegalClicked: (ElementLegal) -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceView( + modifier = modifier, + onBackPressed = onBackPressed, + title = stringResource(id = CommonStrings.common_about) + ) { + state.elementLegals.forEach { elementLegal -> + PreferenceText( + title = stringResource(id = elementLegal.titleRes), + onClick = { onElementLegalClicked(elementLegal) } + ) + } + } +} + +@Preview +@Composable +fun AboutViewLightPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun AboutViewDarkPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: AboutState) { + AboutView( + state = state, + onElementLegalClicked = {}, + onBackPressed = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt new file mode 100644 index 0000000000..81af611716 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import androidx.annotation.StringRes +import io.element.android.libraries.ui.strings.CommonStrings + +private const val CopyrightUrl = "https://element.io/copyright" +private const val UsePolicyUrl = "https://element.io/acceptable-use-policy-terms" +private const val PrivacyUrl = "https://element.io/privacy" + +sealed class ElementLegal( + @StringRes val titleRes: Int, + val url: String, +) { + object Copyright : ElementLegal(CommonStrings.common_copyright, CopyrightUrl) + object AcceptableUsePolicy : ElementLegal(CommonStrings.common_acceptable_use_policy, UsePolicyUrl) + object PrivacyPolicy : ElementLegal(CommonStrings.common_privacy_policy, PrivacyUrl) +} + +fun getAllLegals(): List<ElementLegal> { + return listOf( + ElementLegal.Copyright, + ElementLegal.AcceptableUsePolicy, + ElementLegal.PrivacyPolicy, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt new file mode 100644 index 0000000000..adc917b7e6 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class AnalyticsSettingsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: AnalyticsSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AnalyticsSettingsView( + state = state, + onBackPressed = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenter.kt new file mode 100644 index 0000000000..1ef344403d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenter.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import androidx.compose.runtime.Composable +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +class AnalyticsSettingsPresenter @Inject constructor( + private val analyticsPresenter: AnalyticsPreferencesPresenter, +) : Presenter<AnalyticsSettingsState> { + + @Composable + override fun present(): AnalyticsSettingsState { + val analyticsState = analyticsPresenter.present() + + return AnalyticsSettingsState( + analyticsState = analyticsState, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsState.kt new file mode 100644 index 0000000000..00fb8443c5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState + +// Do not use default value, so no member get forgotten in the presenters. +data class AnalyticsSettingsState( + val analyticsState: AnalyticsPreferencesState, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsStateProvider.kt new file mode 100644 index 0000000000..16ad2394ff --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsStateProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.analytics.api.preferences.aAnalyticsPreferencesState + +open class AnalyticsSettingsStateProvider : PreviewParameterProvider<AnalyticsSettingsState> { + override val values: Sequence<AnalyticsSettingsState> + get() = sequenceOf( + aAnalyticsSettingsState(), + ) +} + +fun aAnalyticsSettingsState() = AnalyticsSettingsState( + analyticsState = aAnalyticsPreferencesState(), +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt new file mode 100644 index 0000000000..165406c6f5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesView +import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AnalyticsSettingsView( + state: AnalyticsSettingsState, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceView( + modifier = modifier, + onBackPressed = onBackPressed, + title = stringResource(id = CommonStrings.common_analytics) + ) { + AnalyticsPreferencesView( + state = state.analyticsState, + ) + } +} + +@Preview +@Composable +fun AnalyticsSettingsViewLightPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun AnalyticsSettingsViewDarkPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: AnalyticsSettingsState) { + AnalyticsSettingsView( + state = state, + onBackPressed = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt new file mode 100644 index 0000000000..bb3879b129 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel + +sealed interface DeveloperSettingsEvents { + data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents + object ClearCache: DeveloperSettingsEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt new file mode 100644 index 0000000000..7b89c7c227 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.airbnb.android.showkase.ui.ShowkaseBrowserActivity +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class DeveloperSettingsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: DeveloperSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val activity = LocalContext.current as Activity + fun openShowkase() { + val intent = ShowkaseBrowserActivity.getIntent( + context = activity, + rootModuleCanonicalName = "io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModule" + ) + activity.startActivity(intent) + } + + val state = presenter.present() + DeveloperSettingsView( + state = state, + modifier = modifier, + onOpenShowkase = ::openShowkase, + onBackPressed = ::navigateUp + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt new file mode 100644 index 0000000000..1a8216ff1b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.SnapshotStateMap +import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DeveloperSettingsPresenter @Inject constructor( + private val featureFlagService: FeatureFlagService, + private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, + private val clearCacheUseCase: ClearCacheUseCase, + private val rageshakePresenter: RageshakePreferencesPresenter, +) : Presenter<DeveloperSettingsState> { + + @Composable + override fun present(): DeveloperSettingsState { + val rageshakeState = rageshakePresenter.present() + + val features = remember { + mutableStateMapOf<String, Feature>() + } + val enabledFeatures = remember { + mutableStateMapOf<String, Boolean>() + } + val cacheSize = remember { + mutableStateOf<Async<String>>(Async.Uninitialized) + } + val clearCacheAction = remember { + mutableStateOf<Async<Unit>>(Async.Uninitialized) + } + LaunchedEffect(Unit) { + FeatureFlags.values().forEach { feature -> + features[feature.key] = feature + enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature) + } + } + val featureUiModels = createUiModels(features, enabledFeatures) + val coroutineScope = rememberCoroutineScope() + // Compute cache size each time the clear cache action value is changed + LaunchedEffect(clearCacheAction.value) { + computeCacheSize(cacheSize) + } + + fun handleEvents(event: DeveloperSettingsEvents) { + when (event) { + is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( + features, + enabledFeatures, + event.feature, + event.isEnabled + ) + DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) + } + } + + return DeveloperSettingsState( + features = featureUiModels.toImmutableList(), + cacheSize = cacheSize.value, + clearCacheAction = clearCacheAction.value, + rageshakeState = rageshakeState, + eventSink = ::handleEvents + ) + } + + @Composable + private fun createUiModels( + features: SnapshotStateMap<String, Feature>, + enabledFeatures: SnapshotStateMap<String, Boolean> + ): List<FeatureUiModel> { + return features.values.map { feature -> + key(feature.key) { + val isEnabled = enabledFeatures[feature.key].orFalse() + remember(feature, isEnabled) { + FeatureUiModel( + key = feature.key, + title = feature.title, + isEnabled = isEnabled + ) + } + } + } + } + + private fun CoroutineScope.updateEnabledFeature( + features: SnapshotStateMap<String, Feature>, + enabledFeatures: SnapshotStateMap<String, Boolean>, + featureUiModel: FeatureUiModel, + enabled: Boolean + ) = launch { + val feature = features[featureUiModel.key] ?: return@launch + if (featureFlagService.setFeatureEnabled(feature, enabled)) { + enabledFeatures[featureUiModel.key] = enabled + } + } + + private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch { + suspend { + computeCacheSizeUseCase() + }.runCatchingUpdatingState(cacheSize) + } + + private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch { + suspend { + clearCacheUseCase() + }.runCatchingUpdatingState(clearCacheAction) + } +} + + + diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt new file mode 100644 index 0000000000..8d79c9241d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import kotlinx.collections.immutable.ImmutableList + +data class DeveloperSettingsState constructor( + val features: ImmutableList<FeatureUiModel>, + val cacheSize: Async<String>, + val rageshakeState: RageshakePreferencesState, + val clearCacheAction: Async<Unit>, + val eventSink: (DeveloperSettingsEvents) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt new file mode 100644 index 0000000000..ee5c897987 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList + +open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> { + override val values: Sequence<DeveloperSettingsState> + get() = sequenceOf( + aDeveloperSettingsState(), + aDeveloperSettingsState().copy(clearCacheAction = Async.Loading()), + ) +} + +fun aDeveloperSettingsState() = DeveloperSettingsState( + features = aFeatureUiModelList(), + rageshakeState = aRageshakePreferencesState(), + cacheSize = Async.Success("1.2 MB"), + clearCacheAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt new file mode 100644 index 0000000000..1ead19154d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.featureflag.ui.FeatureListView +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun DeveloperSettingsView( + state: DeveloperSettingsState, + onOpenShowkase: () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceView( + modifier = modifier, + onBackPressed = onBackPressed, + title = stringResource(id = CommonStrings.common_developer_options) + ) { + // Note: this is OK to hardcode strings in this debug screen. + PreferenceCategory(title = "Feature flags") { + FeatureListContent(state) + } + PreferenceCategory(title = "Showkase") { + PreferenceText( + title = "Open Showkase browser", + onClick = onOpenShowkase + ) + } + RageshakePreferencesView( + state = state.rageshakeState, + ) + val cache = state.cacheSize + PreferenceCategory(title = "Cache", showDivider = false) { + PreferenceText( + title = "Clear cache", + currentValue = cache.dataOrNull(), + loadingCurrentValue = state.cacheSize.isLoading() || state.clearCacheAction.isLoading(), + onClick = { + if (state.clearCacheAction.isLoading().not()) { + state.eventSink(DeveloperSettingsEvents.ClearCache) + } + } + ) + } + } +} + +@Composable +fun FeatureListContent( + state: DeveloperSettingsState, + modifier: Modifier = Modifier +) { + fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) { + state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled)) + } + + FeatureListView( + modifier = modifier, + features = state.features, + onCheckedChange = ::onFeatureEnabled, + ) +} + +@Preview +@Composable +fun DeveloperSettingsViewLightPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun DeveloperSettingsViewDarkPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: DeveloperSettingsState) { + DeveloperSettingsView( + state = state, + onOpenShowkase = {}, + onBackPressed = {} + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt new file mode 100644 index 0000000000..a564927101 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class PreferencesRootNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: PreferencesRootPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onOpenBugReport() + fun onVerifyClicked() + fun onOpenAnalytics() + fun onOpenAbout() + fun onOpenDeveloperSettings() + } + + private fun onOpenBugReport() { + plugins<Callback>().forEach { it.onOpenBugReport() } + } + + private fun onVerifyClicked() { + plugins<Callback>().forEach { it.onVerifyClicked() } + } + + private fun onOpenDeveloperSettings() { + plugins<Callback>().forEach { it.onOpenDeveloperSettings() } + } + + private fun onOpenAnalytics() { + plugins<Callback>().forEach { it.onOpenAnalytics() } + } + + private fun onOpenAbout() { + plugins<Callback>().forEach { it.onOpenAbout() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + PreferencesRootView( + state = state, + modifier = modifier, + onBackPressed = this::navigateUp, + onOpenRageShake = this::onOpenBugReport, + onOpenAnalytics = this::onOpenAnalytics, + onOpenAbout = this::onOpenAbout, + onVerifyClicked = this::onVerifyClicked, + onOpenDeveloperSettings = this::onOpenDeveloperSettings + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt new file mode 100644 index 0000000000..66ff62ee2b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.logout.api.LogoutPreferencePresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.getCurrentUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PreferencesRootPresenter @Inject constructor( + private val logoutPresenter: LogoutPreferencePresenter, + private val matrixClient: MatrixClient, + private val sessionVerificationService: SessionVerificationService, + private val buildType: BuildType, + private val versionFormatter: VersionFormatter, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter<PreferencesRootState> { + + @Composable + override fun present(): PreferencesRootState { + val matrixUser: MutableState<MatrixUser?> = rememberSaveable { + mutableStateOf(null) + } + LaunchedEffect(Unit) { + initialLoad(matrixUser) + } + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + // Session verification status (unknown, not verified, verified) + val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() + val sessionIsNotVerified by remember { + derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified } + } + + val logoutState = logoutPresenter.present() + val showDeveloperSettings = buildType != BuildType.RELEASE + return PreferencesRootState( + logoutState = logoutState, + myUser = matrixUser.value, + version = versionFormatter.get(), + showCompleteVerification = sessionIsNotVerified, + showDeveloperSettings = showDeveloperSettings, + snackbarMessage = snackbarMessage, + ) + } + + private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch { + matrixUser.value = matrixClient.getCurrentUser() + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt new file mode 100644 index 0000000000..2b0963c53c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import io.element.android.features.logout.api.LogoutPreferenceState +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class PreferencesRootState( + val logoutState: LogoutPreferenceState, + val myUser: MatrixUser?, + val version: String, + val showCompleteVerification: Boolean, + val showDeveloperSettings: Boolean, + val snackbarMessage: SnackbarMessage?, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt new file mode 100644 index 0000000000..9dbd54ffff --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import io.element.android.features.logout.api.aLogoutPreferenceState +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.ui.strings.CommonStrings + +fun aPreferencesRootState() = PreferencesRootState( + logoutState = aLogoutPreferenceState(), + myUser = null, + version = "Version 1.1 (1)", + showCompleteVerification = true, + showDeveloperSettings = true, + snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt new file mode 100644 index 0000000000..01d790f8b9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.DeveloperMode +import androidx.compose.material.icons.outlined.Help +import androidx.compose.material.icons.outlined.InsertChart +import androidx.compose.material.icons.outlined.VerifiedUser +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.logout.api.LogoutPreferenceView +import io.element.android.features.preferences.impl.user.UserPreferences +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.LargeHeightPreview +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserProvider +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PreferencesRootView( + state: PreferencesRootState, + onBackPressed: () -> Unit, + onVerifyClicked: () -> Unit, + onOpenAnalytics: () -> Unit, + onOpenRageShake: () -> Unit, + onOpenAbout: () -> Unit, + onOpenDeveloperSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + // Include pref from other modules + PreferenceView( + modifier = modifier, + onBackPressed = onBackPressed, + title = stringResource(id = CommonStrings.common_settings), + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + ) + } + } + ) { + UserPreferences(state.myUser) + if (state.showCompleteVerification) { + PreferenceText( + title = stringResource(id = CommonStrings.action_complete_verification), + icon = Icons.Outlined.VerifiedUser, + onClick = onVerifyClicked, + ) + Divider() + } + PreferenceText( + title = stringResource(id = CommonStrings.common_analytics), + icon = Icons.Outlined.InsertChart, + onClick = onOpenAnalytics, + ) + PreferenceText( + title = stringResource(id = CommonStrings.action_report_bug), + icon = Icons.Outlined.BugReport, + onClick = onOpenRageShake + ) + PreferenceText( + title = stringResource(id = CommonStrings.common_about), + icon = Icons.Outlined.Help, + onClick = onOpenAbout, + ) + if (state.showDeveloperSettings) { + DeveloperPreferencesView(onOpenDeveloperSettings) + } + Divider() + LogoutPreferenceView( + state = state.logoutState, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp, bottom = 24.dp), + textAlign = TextAlign.Center, + text = state.version, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.materialColors.secondary, + ) + } +} + +@Composable +fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) { + PreferenceText( + title = stringResource(id = CommonStrings.common_developer_options), + icon = Icons.Outlined.DeveloperMode, + onClick = onOpenDeveloperSettings + ) +} + +@LargeHeightPreview +@Composable +fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@LargeHeightPreview +@Composable +fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@Composable +private fun ContentToPreview(matrixUser: MatrixUser) { + PreferencesRootView( + state = aPreferencesRootState().copy(myUser = matrixUser), + onBackPressed = {}, + onOpenAnalytics = {}, + onOpenRageShake = {}, + onOpenDeveloperSettings = {}, + onOpenAbout = {}, + onVerifyClicked = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt new file mode 100644 index 0000000000..7eff670951 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +interface VersionFormatter { + fun get(): String +} + +@ContributesBinding(AppScope::class) +class DefaultVersionFormatter @Inject constructor( + private val stringProvider: StringProvider, + private val buildMeta: BuildMeta, +) : VersionFormatter { + override fun get(): String { + return stringProvider.getString( + CommonStrings.settings_version_number, + buildMeta.versionName, + buildMeta.versionCode.toString() + ) + } +} + +class FakeVersionFormatter : VersionFormatter { + override fun get(): String { + return "A Version" + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt new file mode 100644 index 0000000000..07ca0716e9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoilApi::class) + +package io.element.android.features.preferences.impl.tasks + +import android.content.Context +import coil.Coil +import coil.annotation.ExperimentalCoilApi +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.preferences.impl.DefaultCacheService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Provider + +interface ClearCacheUseCase { + suspend operator fun invoke() +} + +@ContributesBinding(SessionScope::class) +class DefaultClearCacheUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, + private val defaultCacheIndexProvider: DefaultCacheService, + private val okHttpClient: Provider<OkHttpClient>, + private val ftueState: FtueState, +) : ClearCacheUseCase { + override suspend fun invoke() = withContext(coroutineDispatchers.io) { + // Clear Matrix cache + matrixClient.clearCache() + // Clear Coil cache + Coil.imageLoader(context).let { + it.diskCache?.clear() + it.memoryCache?.clear() + } + // Clear OkHttp cache + okHttpClient.get().cache?.delete() + // Clear app cache + context.cacheDir.deleteRecursively() + // Clear some settings + ftueState.reset() + // Ensure the app is restarted + defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt new file mode 100644 index 0000000000..661f6493ec --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.tasks + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.androidutils.file.getSizeOfFiles +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface ComputeCacheSizeUseCase { + suspend operator fun invoke(): String +} + +@ContributesBinding(SessionScope::class) +class DefaultComputeCacheSizeUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, + private val fileSizeFormatter: FileSizeFormatter, +) : ComputeCacheSizeUseCase { + override suspend fun invoke(): String = withContext(coroutineDispatchers.io) { + var cumulativeSize = 0L + cumulativeSize += matrixClient.getCacheSize() + // - 4096 to not include the size fo the folder + cumulativeSize += (context.cacheDir.getSizeOfFiles() - 4096).coerceAtLeast(0) + fileSizeFormatter.format(cumulativeSize) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt new file mode 100644 index 0000000000..3e00a588e0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.user + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserHeader +import io.element.android.libraries.matrix.ui.components.MatrixUserWithNullProvider + +@Composable +fun UserPreferences( + user: MatrixUser?, + modifier: Modifier = Modifier, +) { + MatrixUserHeader( + modifier = modifier, + matrixUser = user + ) +} + +@Preview +@Composable +internal fun UserPreferencesLightPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@Preview +@Composable +internal fun UserPreferencesDarkPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@Composable +private fun ContentToPreview(matrixUser: MatrixUser?) { + UserPreferences(matrixUser) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt new file mode 100644 index 0000000000..97fa158d09 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.about + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AboutPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = AboutPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.elementLegals).isEqualTo(getAllLegals()) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsAnalyticsSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsAnalyticsSettingsPresenterTest.kt new file mode 100644 index 0000000000..5382ad0b37 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsAnalyticsSettingsPresenterTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.analytics + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.impl.preferences.DefaultAnalyticsPreferencesPresenter +import io.element.android.features.analytics.test.FakeAnalyticsService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AnalyticsAnalyticsSettingsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val analyticsPresenter = DefaultAnalyticsPreferencesPresenter(FakeAnalyticsService(), aBuildMeta()) + val presenter = AnalyticsSettingsPresenter( + analyticsPresenter, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.analyticsState.isEnabled).isFalse() + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt new file mode 100644 index 0000000000..87a556621c --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.developer + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase +import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase +import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter +import io.element.android.features.rageshake.test.rageshake.FakeRageShake +import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DeveloperSettingsPresenterTest { + @Test + fun `present - ensures initial state is correct`() = runTest { + val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()) + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + FakeComputeCacheSizeUseCase(), + FakeClearCacheUseCase(), + rageshakePresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.features).isEmpty() + assertThat(initialState.clearCacheAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.cacheSize).isEqualTo(Async.Uninitialized) + val loadedState = awaitItem() + assertThat(loadedState.rageshakeState.isEnabled).isFalse() + assertThat(loadedState.rageshakeState.isSupported).isTrue() + assertThat(loadedState.rageshakeState.sensitivity).isEqualTo(1.0f) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ensures feature list is loaded`() = runTest { + val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()) + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + FakeComputeCacheSizeUseCase(), + FakeClearCacheUseCase(), + rageshakePresenter, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val state = awaitItem() + assertThat(state.features).hasSize(FeatureFlags.values().size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { + val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()) + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + FakeComputeCacheSizeUseCase(), + FakeClearCacheUseCase(), + rageshakePresenter, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val stateBeforeEvent = awaitItem() + val featureBeforeEvent = stateBeforeEvent.features.first() + stateBeforeEvent.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(featureBeforeEvent, !featureBeforeEvent.isEnabled)) + val stateAfterEvent = awaitItem() + val featureAfterEvent = stateAfterEvent.features.first() + assertThat(featureBeforeEvent.key).isEqualTo(featureAfterEvent.key) + assertThat(featureBeforeEvent.isEnabled).isNotEqualTo(featureAfterEvent.isEnabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - clear cache`() = runTest { + val rageshakePresenter = DefaultRageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()) + val clearCacheUseCase = FakeClearCacheUseCase() + val presenter = DeveloperSettingsPresenter( + FakeFeatureFlagService(), + FakeComputeCacheSizeUseCase(), + clearCacheUseCase, + rageshakePresenter, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse() + initialState.eventSink(DeveloperSettingsEvents.ClearCache) + val stateAfterEvent = awaitItem() + assertThat(stateAfterEvent.clearCacheAction).isInstanceOf(Async.Loading::class.java) + skipItems(1) + assertThat(awaitItem().clearCacheAction).isInstanceOf(Async.Success::class.java) + assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt new file mode 100644 index 0000000000..f3cf23599f --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.root + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PreferencesRootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val matrixClient = FakeMatrixClient() + val logoutPresenter = DefaultLogoutPreferencePresenter(matrixClient) + val presenter = PreferencesRootPresenter( + logoutPresenter, + matrixClient, + FakeSessionVerificationService(), + BuildType.DEBUG, + FakeVersionFormatter(), + SnackbarDispatcher(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.myUser).isNull() + assertThat(initialState.version).isEqualTo("A Version") + val loadedState = awaitItem() + assertThat(loadedState.logoutState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(loadedState.myUser).isEqualTo( + MatrixUser( + userId = matrixClient.sessionId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL + ) + ) + assertThat(loadedState.showDeveloperSettings).isEqualTo(true) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt new file mode 100644 index 0000000000..7415e09e96 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.tasks + +import io.element.android.tests.testutils.simulateLongTask + +class FakeClearCacheUseCase : ClearCacheUseCase { + var executeHasBeenCalled = false + private set + + override suspend fun invoke() = simulateLongTask { + executeHasBeenCalled = true + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt new file mode 100644 index 0000000000..fa8556630f --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.preferences.impl.tasks + +import io.element.android.tests.testutils.simulateLongTask + +class FakeComputeCacheSizeUseCase : ComputeCacheSizeUseCase { + override suspend fun invoke() = simulateLongTask { + "O kB" + } +} diff --git a/features/rageshake/api/build.gradle.kts b/features/rageshake/api/build.gradle.kts new file mode 100644 index 0000000000..4b7fc44f7a --- /dev/null +++ b/features/rageshake/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.rageshake.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt new file mode 100644 index 0000000000..5a59cb0622 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.bugreport + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface BugReportEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onBugReportSent() + } +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt new file mode 100644 index 0000000000..1336c825ba --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDataStore.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +import kotlinx.coroutines.flow.Flow + +interface CrashDataStore { + fun setCrashData(crashData: String) + + suspend fun resetAppHasCrashed() + fun appHasCrashed(): Flow<Boolean> + fun crashInfo(): Flow<String> + + suspend fun reset() +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionEvents.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionEvents.kt new file mode 100644 index 0000000000..055a8339f6 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +sealed interface CrashDetectionEvents { + object ResetAllCrashData : CrashDetectionEvents + object ResetAppHasCrashed : CrashDetectionEvents +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionPresenter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionPresenter.kt new file mode 100644 index 0000000000..9ec87b9ee3 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionPresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +import io.element.android.libraries.architecture.Presenter + +interface CrashDetectionPresenter : Presenter<CrashDetectionState> diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionState.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionState.kt new file mode 100644 index 0000000000..96fb558de6 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +import androidx.compose.runtime.Immutable + +@Immutable +data class CrashDetectionState( + val crashDetected: Boolean, + val eventSink: (CrashDetectionEvents) -> Unit +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionStateProvider.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionStateProvider.kt new file mode 100644 index 0000000000..184d0ecbbc --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionStateProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +fun aCrashDetectionState() = CrashDetectionState( + crashDetected = false, + eventSink = {} +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt new file mode 100644 index 0000000000..a3350d87e4 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.crash + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.rageshake.api.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun CrashDetectionView( + state: CrashDetectionState, + onOpenBugReport: () -> Unit = { }, +) { + LogCompositions( + tag = "Crash", + msg = "CrashDetectionScreen" + ) + + fun onPopupDismissed() { + state.eventSink(CrashDetectionEvents.ResetAllCrashData) + } + + if (state.crashDetected) { + CrashDetectionContent( + onYesClicked = onOpenBugReport, + onNoClicked = ::onPopupDismissed, + onDismiss = ::onPopupDismissed, + ) + } +} + +@Composable +fun CrashDetectionContent( + onNoClicked: () -> Unit = { }, + onYesClicked: () -> Unit = { }, + onDismiss: () -> Unit = { }, +) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_report_bug), + content = stringResource(id = R.string.crash_detection_dialog_content, /* TODO App name */ "Element"), + submitText = stringResource(id = CommonStrings.action_yes), + cancelText = stringResource(id = CommonStrings.action_no), + onCancelClicked = onNoClicked, + onSubmitClicked = onYesClicked, + onDismiss = onDismiss, + ) +} + +@Preview +@Composable +internal fun CrashDetectionViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun CrashDetectionViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + CrashDetectionView( + state = aCrashDetectionState().copy(crashDetected = true) + ) +} + diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionEvents.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionEvents.kt new file mode 100644 index 0000000000..bfba87a01a --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.detection + +import io.element.android.features.rageshake.api.screenshot.ImageResult + +sealed interface RageshakeDetectionEvents { + object Dismiss : RageshakeDetectionEvents + object Disable : RageshakeDetectionEvents + object StartDetection : RageshakeDetectionEvents + object StopDetection : RageshakeDetectionEvents + data class ProcessScreenshot(val imageResult: ImageResult) : RageshakeDetectionEvents +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionPresenter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionPresenter.kt new file mode 100644 index 0000000000..87d833db63 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionPresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.detection + +import io.element.android.libraries.architecture.Presenter + +interface RageshakeDetectionPresenter : Presenter<RageshakeDetectionState> diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionState.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionState.kt new file mode 100644 index 0000000000..5d6df8bac9 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.detection + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState + +@Immutable +data class RageshakeDetectionState( + val takeScreenshot: Boolean, + val showDialog: Boolean, + val isStarted: Boolean, + val preferenceState: RageshakePreferencesState, + val eventSink: (RageshakeDetectionEvents) -> Unit +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionStateProvider.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionStateProvider.kt new file mode 100644 index 0000000000..83d2bcd758 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.detection + +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState + +fun aRageshakeDetectionState() = RageshakeDetectionState( + takeScreenshot = false, + showDialog = false, + isStarted = false, + preferenceState = aRageshakePreferencesState(), + eventSink = {} +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt new file mode 100644 index 0000000000..d1203cb9b1 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.detection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.Lifecycle +import io.element.android.features.rageshake.api.R +import io.element.android.features.rageshake.api.screenshot.ImageResult +import io.element.android.features.rageshake.api.screenshot.screenshot +import io.element.android.libraries.androidutils.hardware.vibrate +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RageshakeDetectionView( + state: RageshakeDetectionState, + onOpenBugReport: () -> Unit = { }, +) { + LogCompositions( + tag = "Rageshake", + msg = "RageshakeDetectionScreen" + ) + val eventSink = state.eventSink + val context = LocalContext.current + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvents.StartDetection) + Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvents.StopDetection) + else -> Unit + } + } + when { + state.takeScreenshot -> TakeScreenshot( + onScreenshotTaken = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } + ) + state.showDialog -> { + LaunchedEffect(Unit) { + context.vibrate() + } + RageshakeDialogContent( + onNoClicked = { eventSink(RageshakeDetectionEvents.Dismiss) }, + onDisableClicked = { eventSink(RageshakeDetectionEvents.Disable) }, + onYesClicked = onOpenBugReport + ) + } + } +} + +@Composable +private fun TakeScreenshot( + onScreenshotTaken: (ImageResult) -> Unit = {} +) { + val view = LocalView.current + LaunchedEffect(Unit) { + view.screenshot { + onScreenshotTaken(it) + } + } +} + +@Composable +fun RageshakeDialogContent( + onNoClicked: () -> Unit = { }, + onDisableClicked: () -> Unit = { }, + onYesClicked: () -> Unit = { }, +) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_report_bug), + content = stringResource(id = R.string.rageshake_detection_dialog_content), + thirdButtonText = stringResource(id = CommonStrings.action_disable), + submitText = stringResource(id = CommonStrings.action_yes), + cancelText = stringResource(id = CommonStrings.action_no), + onCancelClicked = onNoClicked, + onThirdButtonClicked = onDisableClicked, + onSubmitClicked = onYesClicked, + onDismiss = onNoClicked, + ) +} + +@Preview +@Composable +internal fun RageshakeDialogContentLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RageshakeDialogContentDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + RageshakeDialogContent() +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesEvents.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesEvents.kt new file mode 100644 index 0000000000..365bfd7397 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.preferences + +sealed interface RageshakePreferencesEvents { + data class SetSensitivity(val sensitivity: Float) : RageshakePreferencesEvents + data class SetIsEnabled(val isEnabled: Boolean) : RageshakePreferencesEvents +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesPresenter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesPresenter.kt new file mode 100644 index 0000000000..dff0afa96e --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesPresenter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.preferences + +import io.element.android.libraries.architecture.Presenter + +interface RageshakePreferencesPresenter : Presenter<RageshakePreferencesState> diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt new file mode 100644 index 0000000000..20a88d5494 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.preferences + +data class RageshakePreferencesState( + val isEnabled: Boolean, + val isSupported: Boolean, + val sensitivity: Float, + val eventSink: (RageshakePreferencesEvents) -> Unit, +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt new file mode 100644 index 0000000000..0af9639242 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.preferences + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RageshakePreferencesStateProvider : PreviewParameterProvider<RageshakePreferencesState> { + override val values: Sequence<RageshakePreferencesState> + get() = sequenceOf( + aRageshakePreferencesState().copy(isEnabled = true, isSupported = true, sensitivity = 0.5f), + aRageshakePreferencesState().copy(isEnabled = true, isSupported = false, sensitivity = 0.5f), + ) +} + +fun aRageshakePreferencesState() = RageshakePreferencesState( + isEnabled = false, + isSupported = true, + sensitivity = 0.3f, + eventSink = {} +) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt new file mode 100644 index 0000000000..73e04fb5d4 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.preferences + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceSlide +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RageshakePreferencesView( + state: RageshakePreferencesState, + modifier: Modifier = Modifier, +) { + fun onSensitivityChanged(sensitivity: Float) { + state.eventSink(RageshakePreferencesEvents.SetSensitivity(sensitivity = sensitivity)) + } + + fun onEnabledChanged(isEnabled: Boolean) { + state.eventSink(RageshakePreferencesEvents.SetIsEnabled(isEnabled = isEnabled)) + } + + Column(modifier = modifier) { + PreferenceCategory(title = stringResource(id = CommonStrings.settings_rageshake)) { + if (state.isSupported) { + PreferenceSwitch( + title = stringResource(id = CommonStrings.preference_rageshake), + isChecked = state.isEnabled, + onCheckedChange = ::onEnabledChanged + ) + PreferenceSlide( + title = stringResource(id = CommonStrings.settings_rageshake_detection_threshold), + // summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary), + value = state.sensitivity, + enabled = state.isEnabled, + steps = 3 /* 5 possible values - steps are in ]0, 1[ */, + onValueChange = ::onSensitivityChanged + ) + } else { + PreferenceText(title = "Rageshaking is not supported by your device") + } + } + } +} + +@Preview +@Composable +fun RageshakePreferencesViewLightPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RageshakePreferencesViewDarkPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RageshakePreferencesState) { + RageshakePreferencesView(state) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt new file mode 100644 index 0000000000..02661e658f --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageShake.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.rageshake + +interface RageShake { + /** + * Check if the feature is available on this device. + */ + fun isAvailable(): Boolean + + fun start(sensitivity: Float) + + fun stop() + + /** + * sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to + * [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)]. + */ + fun setSensitivity(sensitivity: Float) + + fun setInterceptor(interceptor: (() -> Unit)?) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt new file mode 100644 index 0000000000..0c45d24e31 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/rageshake/RageshakeDataStore.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.rageshake + +import kotlinx.coroutines.flow.Flow + +interface RageshakeDataStore { + fun isEnabled(): Flow<Boolean> + + suspend fun setIsEnabled(isEnabled: Boolean) + + fun sensitivity(): Flow<Float> + + suspend fun setSensitivity(sensitivity: Float) + + suspend fun reset() +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt new file mode 100644 index 0000000000..0af13dcdda --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.reporter + +interface BugReporter { + /** + * Send a bug report. + * + * @param reportType The report type (bug, suggestion, feedback) + * @param withDevicesLogs true to include the device log + * @param withCrashLogs true to include the crash logs + * @param withKeyRequestHistory true to include the crash logs + * @param withScreenshot true to include the screenshot + * @param theBugDescription the bug description + * @param serverVersion version of the server + * @param canContact true if the user opt in to be contacted directly + * @param customFields fields which will be sent with the report + * @param listener the listener + */ + suspend fun sendBugReport( + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean = false, + customFields: Map<String, String>? = null, + listener: BugReporterListener? + ) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporterListener.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporterListener.kt new file mode 100644 index 0000000000..8f2ae90d1c --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporterListener.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.reporter + +/** + * Bug report upload listener. + */ +interface BugReporterListener { + /** + * The bug report has been cancelled. + */ + fun onUploadCancelled() + + /** + * The bug report upload failed. + * + * @param reason the failure reason + */ + fun onUploadFailed(reason: String?) + + /** + * The upload progress (in percent). + * + * @param progress the upload progress + */ + fun onProgress(progress: Int) + + /** + * The bug report upload succeeded. + */ + fun onUploadSucceed(reportUrl: String?) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/ReportType.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/ReportType.kt new file mode 100644 index 0000000000..17b75ea1a7 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/ReportType.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.reporter + +enum class ReportType { + BUG_REPORT, + SUGGESTION, + SPACE_BETA_FEEDBACK, + THREADS_BETA_FEEDBACK, + AUTO_UISI, + AUTO_UISI_SENDER, +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/Screenshot.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/Screenshot.kt new file mode 100644 index 0000000000..13041b24ac --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/Screenshot.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.screenshot + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View + +fun View.screenshot(bitmapCallback: (ImageResult) -> Unit) { + try { + val handler = Handler(Looper.getMainLooper()) + val bitmap = Bitmap.createBitmap( + width, + height, + Bitmap.Config.ARGB_8888, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PixelCopy.request( + (this.context as Activity).window, + clipBounds, + bitmap, + { + when (it) { + PixelCopy.SUCCESS -> { + bitmapCallback.invoke(ImageResult.Success(bitmap)) + } + else -> { + bitmapCallback.invoke(ImageResult.Error(Exception(it.toString()))) + } + } + }, + handler + ) + } else { + handler.post { + val canvas = Canvas(bitmap) + .apply { + translate(-clipBounds.left.toFloat(), -clipBounds.top.toFloat()) + } + this.draw(canvas) + canvas.setBitmap(null) + bitmapCallback.invoke(ImageResult.Success(bitmap)) + } + } + } catch (e: Exception) { + bitmapCallback.invoke(ImageResult.Error(e)) + } +} + +sealed interface ImageResult { + data class Error(val exception: Exception) : ImageResult + data class Success(val data: Bitmap) : ImageResult +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt new file mode 100644 index 0000000000..abd79bd77b --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/ScreenshotHolder.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.api.screenshot + +import android.graphics.Bitmap + +interface ScreenshotHolder { + fun writeBitmap(data: Bitmap) + fun getFileUri(): String? + fun reset() +} diff --git a/features/rageshake/api/src/main/res/values-cs/translations.xml b/features/rageshake/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..20d6f31ed0 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string> + <string name="rageshake_detection_dialog_content">"Zdá se, že jste frustrovaně třásli telefonem. Chcete otevřít obrazovku pro nahlášení chyby?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-de/translations.xml b/features/rageshake/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..f2446a4028 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"</string> + <string name="rageshake_detection_dialog_content">"Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-es/translations.xml b/features/rageshake/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..597ec74260 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string> + <string name="rageshake_detection_dialog_content">"Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-fr/translations.xml b/features/rageshake/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..455ab1daef --- /dev/null +++ b/features/rageshake/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"</string> + <string name="rageshake_detection_dialog_content">"Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-it/translations.xml b/features/rageshake/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..6d5e7a74c0 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"</string> + <string name="rageshake_detection_dialog_content">"Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-ro/translations.xml b/features/rageshake/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..2c89703deb --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"</string> + <string name="rageshake_detection_dialog_content">"Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values-sk/translations.xml b/features/rageshake/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..753ead0b97 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?"</string> + <string name="rageshake_detection_dialog_content">"Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?"</string> +</resources> diff --git a/features/rageshake/api/src/main/res/values/localazy.xml b/features/rageshake/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..bb694f2d00 --- /dev/null +++ b/features/rageshake/api/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="crash_detection_dialog_content">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string> + <string name="rageshake_detection_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string> +</resources> diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts new file mode 100644 index 0000000000..137a3bd070 --- /dev/null +++ b/features/rageshake/impl/build.gradle.kts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.rageshake.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.network) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.sessionStorage.api) + api(libs.squareup.seismic) + api(projects.features.rageshake.api) + implementation(libs.androidx.datastore.preferences) + implementation(platform(libs.network.okhttp.bom)) + implementation("com.squareup.okhttp3:okhttp") + implementation(libs.coil) + implementation(libs.coil.compose) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.mockk) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.rageshake.test) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/rageshake/impl/src/main/java/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.java b/features/rageshake/impl/src/main/java/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.java new file mode 100755 index 0000000000..e114cb3901 --- /dev/null +++ b/features/rageshake/impl/src/main/java/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.reporter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okhttp3.internal.Util; +import okio.Buffer; +import okio.BufferedSink; +import okio.ByteString; + +// simplified version of MultipartBody (OkHttp 3.6.0) +public class BugReporterMultipartBody extends RequestBody { + + /** + * Listener + */ + public interface WriteListener { + /** + * Upload listener + * + * @param totalWritten total written bytes + * @param contentLength content length + */ + void onWrite(long totalWritten, long contentLength); + } + + private static final MediaType FORM = MediaType.parse("multipart/form-data"); + + private static final byte[] COLONSPACE = {':', ' '}; + private static final byte[] CRLF = {'\r', '\n'}; + private static final byte[] DASHDASH = {'-', '-'}; + + private final ByteString mBoundary; + private final MediaType mContentType; + private final List<Part> mParts; + private long mContentLength = -1L; + + // listener + private WriteListener mWriteListener; + + // + private List<Long> mContentLengthSize = null; + + private BugReporterMultipartBody(ByteString boundary, List<Part> parts) { + mBoundary = boundary; + mContentType = MediaType.parse(FORM + "; boundary=" + boundary.utf8()); + mParts = Util.toImmutableList(parts); + } + + @Override + public MediaType contentType() { + return mContentType; + } + + @Override + public long contentLength() throws IOException { + long result = mContentLength; + if (result != -1L) return result; + return mContentLength = writeOrCountBytes(null, true); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + writeOrCountBytes(sink, false); + } + + /** + * Set the listener + * + * @param listener the + */ + public void setWriteListener(WriteListener listener) { + mWriteListener = listener; + } + + /** + * Warn the listener that some bytes have been written + * + * @param totalWrittenBytes the total written bytes + */ + private void onWrite(long totalWrittenBytes) { + if ((null != mWriteListener) && (mContentLength > 0)) { + mWriteListener.onWrite(totalWrittenBytes, mContentLength); + } + } + + /** + * Either writes this request to {@code sink} or measures its content length. We have one method + * do double-duty to make sure the counting and content are consistent, particularly when it comes + * to awkward operations like measuring the encoded length of header strings, or the + * length-in-digits of an encoded integer. + */ + private long writeOrCountBytes(BufferedSink sink, boolean countBytes) throws IOException { + long byteCount = 0L; + + Buffer byteCountBuffer = null; + if (countBytes) { + sink = byteCountBuffer = new Buffer(); + mContentLengthSize = new ArrayList<>(); + } + + for (int p = 0, partCount = mParts.size(); p < partCount; p++) { + Part part = mParts.get(p); + Headers headers = part.headers; + RequestBody body = part.body; + + sink.write(DASHDASH); + sink.write(mBoundary); + sink.write(CRLF); + + if (headers != null) { + for (int h = 0, headerCount = headers.size(); h < headerCount; h++) { + sink.writeUtf8(headers.name(h)) + .write(COLONSPACE) + .writeUtf8(headers.value(h)) + .write(CRLF); + } + } + + MediaType contentType = body.contentType(); + if (contentType != null) { + sink.writeUtf8("Content-Type: ") + .writeUtf8(contentType.toString()) + .write(CRLF); + } + + int contentLength = (int) body.contentLength(); + if (contentLength != -1) { + sink.writeUtf8("Content-Length: ") + .writeUtf8(contentLength + "") + .write(CRLF); + } else if (countBytes) { + // We can't measure the body's size without the sizes of its components. + byteCountBuffer.clear(); + return -1L; + } + + sink.write(CRLF); + + if (countBytes) { + byteCount += contentLength; + mContentLengthSize.add(byteCount); + } else { + body.writeTo(sink); + + // warn the listener of upload progress + // sink.buffer().size() does not give the right value + // assume that some data are popped + if ((null != mContentLengthSize) && (p < mContentLengthSize.size())) { + onWrite(mContentLengthSize.get(p)); + } + } + sink.write(CRLF); + } + + sink.write(DASHDASH); + sink.write(mBoundary); + sink.write(DASHDASH); + sink.write(CRLF); + + if (countBytes) { + byteCount += byteCountBuffer.size(); + byteCountBuffer.clear(); + } + + return byteCount; + } + + private static void appendQuotedString(StringBuilder target, String key) { + target.append('"'); + for (int i = 0, len = key.length(); i < len; i++) { + char ch = key.charAt(i); + switch (ch) { + case '\n': + target.append("%0A"); + break; + case '\r': + target.append("%0D"); + break; + case '"': + target.append("%22"); + break; + default: + target.append(ch); + break; + } + } + target.append('"'); + } + + public static final class Part { + public static Part create(Headers headers, RequestBody body) { + if (body == null) { + throw new NullPointerException("body == null"); + } + if (headers != null && headers.get("Content-Type") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Type"); + } + if (headers != null && headers.get("Content-Length") != null) { + throw new IllegalArgumentException("Unexpected header: Content-Length"); + } + return new Part(headers, body); + } + + public static Part createFormData(String name, String value) { + return createFormData(name, null, RequestBody.create(value, null)); + } + + public static Part createFormData(String name, String filename, RequestBody body) { + if (name == null) { + throw new NullPointerException("name == null"); + } + StringBuilder disposition = new StringBuilder("form-data; name="); + appendQuotedString(disposition, name); + + if (filename != null) { + disposition.append("; filename="); + appendQuotedString(disposition, filename); + } + + return create(Headers.of("Content-Disposition", disposition.toString()), body); + } + + final Headers headers; + final RequestBody body; + + private Part(Headers headers, RequestBody body) { + this.headers = headers; + this.body = body; + } + } + + public static final class Builder { + private final ByteString boundary; + private final List<Part> parts = new ArrayList<>(); + + public Builder() { + this(UUID.randomUUID().toString()); + } + + public Builder(String boundary) { + this.boundary = ByteString.encodeUtf8(boundary); + } + + /** + * Add a form data part to the body. + */ + public Builder addFormDataPart(String name, String value) { + return addPart(Part.createFormData(name, value)); + } + + /** + * Add a form data part to the body. + */ + public Builder addFormDataPart(String name, String filename, RequestBody body) { + return addPart(Part.createFormData(name, filename, body)); + } + + /** + * Add a part to the body. + */ + public Builder addPart(Part part) { + if (part == null) throw new NullPointerException("part == null"); + parts.add(part); + return this; + } + + /** + * Assemble the specified parts into a request body. + */ + public BugReporterMultipartBody build() { + if (parts.isEmpty()) { + throw new IllegalStateException("Multipart body must have at least one part."); + } + return new BugReporterMultipartBody(boundary, parts); + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportEvents.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportEvents.kt new file mode 100644 index 0000000000..9765f83da0 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportEvents.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +sealed interface BugReportEvents { + object SendBugReport : BugReportEvents + object ResetAll : BugReportEvents + object ClearError : BugReportEvents + + data class SetDescription(val description: String) : BugReportEvents + data class SetSendLog(val sendLog: Boolean) : BugReportEvents + data class SetCanContact(val canContact: Boolean) : BugReportEvents + data class SetSendScreenshot(val sendScreenshot: Boolean) : BugReportEvents +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt new file mode 100644 index 0000000000..db9c2b5aa1 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.ui.strings.CommonStrings + +@ContributesNode(AppScope::class) +class BugReportNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: BugReportPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = LocalContext.current as? Activity + BugReportView( + state = state, + modifier = modifier, + onBackPressed = { navigateUp() }, + onDone = { + activity?.toast(CommonStrings.common_report_submitted) + onDone() + }, + ) + } + + private fun onDone() { + plugins<BugReportEntryPoint.Callback>().forEach { it.onBugReportSent() } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt new file mode 100644 index 0000000000..5fd95e79bd --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.api.reporter.ReportType +import io.element.android.features.rageshake.api.crash.CrashDataStore +import io.element.android.features.rageshake.impl.logs.VectorFileLogger +import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class BugReportPresenter @Inject constructor( + private val bugReporter: BugReporter, + private val crashDataStore: CrashDataStore, + private val screenshotHolder: ScreenshotHolder, + private val appCoroutineScope: CoroutineScope, +) : Presenter<BugReportState> { + + private class BugReporterUploadListener( + private val sendingProgress: MutableState<Float>, + private val sendingAction: MutableState<Async<Unit>> + ) : BugReporterListener { + + override fun onUploadCancelled() { + sendingProgress.value = 0f + sendingAction.value = Async.Uninitialized + } + + override fun onUploadFailed(reason: String?) { + sendingProgress.value = 0f + sendingAction.value = Async.Failure(Exception(reason)) + } + + override fun onProgress(progress: Int) { + sendingProgress.value = progress.toFloat() / 100 + sendingAction.value = Async.Loading() + } + + override fun onUploadSucceed(reportUrl: String?) { + sendingProgress.value = 0f + sendingAction.value = Async.Success(Unit) + } + } + + @Composable + override fun present(): BugReportState { + val screenshotUri = rememberSaveable { + mutableStateOf( + screenshotHolder.getFileUri() + ) + } + val crashInfo: String by crashDataStore + .crashInfo() + .collectAsState(initial = "") + + val sendingProgress = remember { + mutableStateOf(0f) + } + val sendingAction: MutableState<Async<Unit>> = remember { + mutableStateOf(Async.Uninitialized) + } + val formState: MutableState<BugReportFormState> = remember { + mutableStateOf(BugReportFormState.Default) + } + val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction) + + fun handleEvents(event: BugReportEvents) { + when (event) { + BugReportEvents.SendBugReport -> appCoroutineScope.sendBugReport(formState.value, crashInfo.isNotEmpty(), uploadListener) + BugReportEvents.ResetAll -> appCoroutineScope.resetAll() + is BugReportEvents.SetDescription -> updateFormState(formState) { + copy(description = event.description) + } + is BugReportEvents.SetCanContact -> updateFormState(formState) { + copy(canContact = event.canContact) + } + is BugReportEvents.SetSendLog -> updateFormState(formState) { + copy(sendLogs = event.sendLog) + } + is BugReportEvents.SetSendScreenshot -> updateFormState(formState) { + copy(sendScreenshot = event.sendScreenshot) + } + BugReportEvents.ClearError -> { + sendingProgress.value = 0f + sendingAction.value = Async.Uninitialized + } + } + } + + return BugReportState( + hasCrashLogs = crashInfo.isNotEmpty(), + sendingProgress = sendingProgress.value, + sending = sendingAction.value, + formState = formState.value, + screenshotUri = screenshotUri.value, + eventSink = ::handleEvents + ) + } + + private fun updateFormState(formState: MutableState<BugReportFormState>, operation: BugReportFormState.() -> BugReportFormState) { + formState.value = operation(formState.value) + } + + private fun CoroutineScope.sendBugReport( + formState: BugReportFormState, + hasCrashLogs: Boolean, + listener: BugReporterListener, + ) = launch { + bugReporter.sendBugReport( + reportType = ReportType.BUG_REPORT, + withDevicesLogs = formState.sendLogs, + withCrashLogs = hasCrashLogs && formState.sendLogs, + withKeyRequestHistory = false, + withScreenshot = formState.sendScreenshot, + theBugDescription = formState.description, + serverVersion = "", + canContact = formState.canContact, + customFields = emptyMap(), + listener = listener + ) + } + + private fun CoroutineScope.resetAll() = launch { + screenshotHolder.reset() + crashDataStore.reset() + VectorFileLogger.getFromTimber()?.reset() + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportState.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportState.kt new file mode 100644 index 0000000000..b8bbe62dc6 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportState.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import android.os.Parcelable +import io.element.android.libraries.architecture.Async +import kotlinx.parcelize.Parcelize + +data class BugReportState( + val formState: BugReportFormState, + val hasCrashLogs: Boolean, + val screenshotUri: String?, + val sendingProgress: Float, + val sending: Async<Unit>, + val eventSink: (BugReportEvents) -> Unit +) { + val submitEnabled = + formState.description.length > 10 && sending !is Async.Loading +} + +@Parcelize +data class BugReportFormState( + val description: String, + val sendLogs: Boolean, + val canContact: Boolean, + val sendScreenshot: Boolean +) : Parcelable { + companion object { + val Default = BugReportFormState( + description = "", + sendLogs = true, + canContact = false, + sendScreenshot = false + ) + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportStateProvider.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportStateProvider.kt new file mode 100644 index 0000000000..3601c2a259 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class BugReportStateProvider : PreviewParameterProvider<BugReportState> { + override val values: Sequence<BugReportState> + get() = sequenceOf( + aBugReportState(), + aBugReportState().copy( + formState = BugReportFormState.Default.copy( + description = "A long enough description", + sendScreenshot = true, + ), + hasCrashLogs = true, + screenshotUri = "aUri" + ), + aBugReportState().copy(sending = Async.Loading()), + aBugReportState().copy(sending = Async.Success(Unit)), + ) +} + +fun aBugReportState() = BugReportState( + formState = BugReportFormState.Default, + hasCrashLogs = false, + screenshotUri = null, + sendingProgress = 0F, + sending = Async.Uninitialized, + eventSink = {} +) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt new file mode 100644 index 0000000000..74f3a13d13 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import io.element.android.features.rageshake.impl.R +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.components.preferences.PreferenceRow +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.components.preferences.PreferenceView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun BugReportView( + state: BugReportState, + onDone: () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + LogCompositions(tag = "Rageshake", msg = "Root") + val eventSink = state.eventSink + if (state.sending is Async.Success) { + LaunchedEffect(state.sending) { + eventSink(BugReportEvents.ResetAll) + onDone() + } + return + } + + Box(modifier = modifier) { + PreferenceView( + title = stringResource(id = CommonStrings.common_report_a_bug), + onBackPressed = onBackPressed + ) { + val isFormEnabled = state.sending !is Async.Loading + var descriptionFieldState by textFieldState( + stateValue = state.formState.description + ) + Spacer(modifier = Modifier.height(16.dp)) + PreferenceRow { + OutlinedTextField( + value = descriptionFieldState, + modifier = Modifier.fillMaxWidth(), + enabled = isFormEnabled, + label = { + Text(text = stringResource(id = R.string.screen_bug_report_editor_placeholder)) + }, + supportingText = { + Text(text = stringResource(id = R.string.screen_bug_report_editor_description)) + }, + onValueChange = { + descriptionFieldState = it + eventSink(BugReportEvents.SetDescription(it)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + minLines = 3, + // TODO Error text too short + ) + } + Spacer(modifier = Modifier.height(16.dp)) + PreferenceSwitch( + isChecked = state.formState.sendLogs, + onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) }, + enabled = isFormEnabled, + title = stringResource(id = R.string.screen_bug_report_include_logs), + subtitle = stringResource(id = R.string.screen_bug_report_logs_description), + ) + PreferenceSwitch( + isChecked = state.formState.canContact, + onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) }, + enabled = isFormEnabled, + title = stringResource(id = R.string.screen_bug_report_contact_me_title), + subtitle = stringResource(id = R.string.screen_bug_report_contact_me), + ) + if (state.screenshotUri != null) { + PreferenceSwitch( + isChecked = state.formState.sendScreenshot, + onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) }, + enabled = isFormEnabled, + title = stringResource(id = R.string.screen_bug_report_include_screenshot) + ) + if (state.formState.sendScreenshot) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val context = LocalContext.current + val model = ImageRequest.Builder(context) + .data(state.screenshotUri) + .build() + AsyncImage( + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + model = model, + contentDescription = null, + placeholder = debugPlaceholderBackground(), + ) + } + } + } + // Submit + PreferenceRow { + Button( + onClick = { eventSink(BugReportEvents.SendBugReport) }, + enabled = state.submitEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 16.dp) + ) { + Text(text = stringResource(id = CommonStrings.action_send)) + } + } + } + + when (state.sending) { + is Async.Loading -> { + // Indeterminate indicator, to avoid the freeze effect if the connection takes time to initialize. + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + is Async.Failure -> ErrorDialog( + content = state.sending.error.toString(), + onDismiss = { state.eventSink(BugReportEvents.ClearError) } + ) + else -> Unit + } + } +} + +@Preview +@Composable +fun BugReportViewLightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun BugReportViewDarkPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: BugReportState) { + BugReportView( + state = state, + onDone = {}, + onBackPressed = {}, + ) +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPoint.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPoint.kt new file mode 100644 index 0000000000..5abaec94b6 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPoint.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultBugReportEntryPoint @Inject constructor() : BugReportEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): BugReportEntryPoint.NodeBuilder { + + val plugins = ArrayList<Plugin>() + + return object : BugReportEntryPoint.NodeBuilder { + override fun callback(callback: BugReportEntryPoint.Callback): BugReportEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<BugReportNode>(buildContext, plugins) + } + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt new file mode 100644 index 0000000000..6a15553bdb --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.crash + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.crash.CrashDetectionEvents +import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter +import io.element.android.features.rageshake.api.crash.CrashDetectionState +import io.element.android.features.rageshake.api.crash.CrashDataStore +import io.element.android.libraries.di.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultCrashDetectionPresenter @Inject constructor(private val crashDataStore: CrashDataStore) : + CrashDetectionPresenter { + + @Composable + override fun present(): CrashDetectionState { + val localCoroutineScope = rememberCoroutineScope() + val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false) + + fun handleEvents(event: CrashDetectionEvents) { + when (event) { + CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll() + CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed() + } + } + + return CrashDetectionState( + crashDetected = crashDetected.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.resetAppHasCrashed() = launch { + crashDataStore.resetAppHasCrashed() + } + + private fun CoroutineScope.resetAll() = launch { + crashDataStore.reset() + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt new file mode 100644 index 0000000000..018b30d4c5 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.crash + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.crash.CrashDataStore +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash") + +private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") +private val crashDataKey = stringPreferencesKey("crashData") + +@ContributesBinding(AppScope::class) +class PreferencesCrashDataStore @Inject constructor( + @ApplicationContext context: Context +) : CrashDataStore { + private val store = context.dataStore + + override fun setCrashData(crashData: String) { + // Must block + runBlocking { + store.edit { prefs -> + prefs[appHasCrashedKey] = true + prefs[crashDataKey] = crashData + } + } + } + + override suspend fun resetAppHasCrashed() { + store.edit { prefs -> + prefs[appHasCrashedKey] = false + } + } + + override fun appHasCrashed(): Flow<Boolean> { + return store.data.map { prefs -> + prefs[appHasCrashedKey].orFalse() + } + } + + override fun crashInfo(): Flow<String> { + return store.data.map { prefs -> + prefs[crashDataKey].orEmpty() + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt new file mode 100644 index 0000000000..a5e7edf405 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.crash + +import android.content.Context +import android.os.Build +import io.element.android.libraries.core.data.tryOrNull +import timber.log.Timber +import java.io.PrintWriter +import java.io.StringWriter + +class VectorUncaughtExceptionHandler( + context: Context +) : Thread.UncaughtExceptionHandler { + private val crashDataStore = PreferencesCrashDataStore(context) + private var previousHandler: Thread.UncaughtExceptionHandler? = null + + /** + * Activate this handler. + */ + fun activate() { + previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + } + + /** + * An uncaught exception has been triggered. + * + * @param thread the thread + * @param throwable the throwable + */ + @Suppress("PrintStackTrace") + override fun uncaughtException(thread: Thread, throwable: Throwable) { + Timber.v("Uncaught exception: $throwable") + val bugDescription = buildString { + val appName = "ElementX" + // append(appName + " Build : " + versionCodeProvider.getVersionCode() + "\n") + append("$appName Version : 1.0") // ${versionProvider.getVersion(longFormat = true)}\n") + // append("SDK Version : ${Matrix.getSdkVersion()}\n") + append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n") + append("Memory statuses \n") + var freeSize = 0L + var totalSize = 0L + var usedSize = -1L + tryOrNull { + val info = Runtime.getRuntime() + freeSize = info.freeMemory() + totalSize = info.totalMemory() + usedSize = totalSize - freeSize + } + append("usedSize " + usedSize / 1048576L + " MB\n") + append("freeSize " + freeSize / 1048576L + " MB\n") + append("totalSize " + totalSize / 1048576L + " MB\n") + append("Thread: ") + append(thread.name) + append(", Exception: ") + val sw = StringWriter() + val pw = PrintWriter(sw, true) + throwable.printStackTrace(pw) + append(sw.buffer.toString()) + } + Timber.e("FATAL EXCEPTION $bugDescription") + crashDataStore.setCrashData(bugDescription) + // Show the classical system popup + previousHandler?.uncaughtException(thread, throwable) + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt new file mode 100644 index 0000000000..58a49611be --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.detection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents +import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter +import io.element.android.features.rageshake.api.detection.RageshakeDetectionState +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter +import io.element.android.features.rageshake.api.rageshake.RageShake +import io.element.android.features.rageshake.api.screenshot.ImageResult +import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder +import io.element.android.libraries.di.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultRageshakeDetectionPresenter @Inject constructor( + private val screenshotHolder: ScreenshotHolder, + private val rageShake: RageShake, + private val preferencesPresenter: RageshakePreferencesPresenter, +) : RageshakeDetectionPresenter { + + @Composable + override fun present(): RageshakeDetectionState { + val localCoroutineScope = rememberCoroutineScope() + val preferencesState = preferencesPresenter.present() + val isStarted = rememberSaveable { + mutableStateOf(false) + } + val takeScreenshot = rememberSaveable { + mutableStateOf(false) + } + val showDialog = rememberSaveable { + mutableStateOf(false) + } + + fun handleEvents(event: RageshakeDetectionEvents) { + when (event) { + RageshakeDetectionEvents.Disable -> { + preferencesState.eventSink(RageshakePreferencesEvents.SetIsEnabled(false)) + showDialog.value = false + } + RageshakeDetectionEvents.StartDetection -> isStarted.value = true + RageshakeDetectionEvents.StopDetection -> isStarted.value = false + is RageshakeDetectionEvents.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult) + RageshakeDetectionEvents.Dismiss -> showDialog.value = false + } + } + + val state = remember(preferencesState, isStarted.value, takeScreenshot.value, showDialog.value) { + RageshakeDetectionState( + isStarted = isStarted.value, + takeScreenshot = takeScreenshot.value, + showDialog = showDialog.value, + preferenceState = preferencesState, + eventSink = ::handleEvents + ) + } + + LaunchedEffect(preferencesState.sensitivity) { + rageShake.setSensitivity(preferencesState.sensitivity) + } + val shouldStart = preferencesState.isEnabled && + preferencesState.isSupported && + isStarted.value && + !takeScreenshot.value && + !showDialog.value + + LaunchedEffect(shouldStart) { + handleRageShake(shouldStart, state, takeScreenshot) + } + return state + } + + private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) { + if (start) { + rageShake.start(state.preferenceState.sensitivity) + rageShake.setInterceptor { + takeScreenshot.value = true + } + } else { + rageShake.stop() + rageShake.setInterceptor(null) + } + } + + private fun CoroutineScope.processScreenshot(takeScreenshot: MutableState<Boolean>, showDialog: MutableState<Boolean>, imageResult: ImageResult) = launch { + screenshotHolder.reset() + when (imageResult) { + is ImageResult.Error -> { + Timber.e(imageResult.exception, "Unable to write screenshot") + } + is ImageResult.Success -> { + screenshotHolder.writeBitmap(imageResult.data) + } + } + takeScreenshot.value = false + showDialog.value = true + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt new file mode 100644 index 0000000000..e0dc1a5fbb --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/LogFormatter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.logs + +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.logging.Formatter +import java.util.logging.LogRecord + +internal class LogFormatter : Formatter() { + + override fun format(r: LogRecord): String { + if (!mIsTimeZoneSet) { + DATE_FORMAT.timeZone = TimeZone.getTimeZone("UTC") + mIsTimeZoneSet = true + } + + val thrown = r.thrown + if (thrown != null) { + val sw = StringWriter() + val pw = PrintWriter(sw) + sw.write(r.message) + sw.write(LINE_SEPARATOR) + thrown.printStackTrace(pw) + pw.flush() + return sw.toString() + } else { + val b = StringBuilder() + val date = DATE_FORMAT.format(Date(r.millis)) + b.append(date) + b.append("Z ") + b.append(r.message) + b.append(LINE_SEPARATOR) + return b.toString() + } + } + + companion object { + private val LINE_SEPARATOR = System.getProperty("line.separator") ?: "\n" + + // private val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US) + private val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss*SSSZZZZ", Locale.US) + + private var mIsTimeZoneSet = false + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt new file mode 100644 index 0000000000..eea6c1dbbf --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/VectorFileLogger.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.logs + +import android.content.Context +import android.util.Log +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.data.tryOrNull +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.util.logging.FileHandler +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Will be planted in Timber. + */ +class VectorFileLogger( + context: Context, + // private val vectorPreferences: VectorPreferences + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) : Timber.Tree() { + + companion object { + fun getFromTimber(): VectorFileLogger? { + return Timber.forest().filterIsInstance<VectorFileLogger>().firstOrNull() + } + + private const val SIZE_20MB = 20 * 1024 * 1024 + private const val SIZE_50MB = 50 * 1024 * 1024 + } + + /* + private val maxLogSizeByte = if (vectorPreferences.labAllowedExtendedLogging()) SIZE_50MB else SIZE_20MB + private val logRotationCount = if (vectorPreferences.labAllowedExtendedLogging()) 15 else 7 + */ + private val maxLogSizeByte = SIZE_20MB + private val logRotationCount = 7 + + private val logger = Logger.getLogger(context.packageName).apply { + tryOrNull { + useParentHandlers = false + level = Level.ALL + } + } + + private val fileHandler: FileHandler? + private val cacheDirectory = File(context.cacheDir, "logs") + private var fileNamePrefix = "logs" + + private val prioPrefixes = mapOf( + Log.VERBOSE to "V/ ", + Log.DEBUG to "D/ ", + Log.INFO to "I/ ", + Log.WARN to "W/ ", + Log.ERROR to "E/ ", + Log.ASSERT to "WTF/ " + ) + + init { + if (!cacheDirectory.exists()) { + cacheDirectory.mkdirs() + } + + for (i in 0..15) { + val file = File(cacheDirectory, "elementLogs.${i}.txt") + file.safeDelete() + } + + fileHandler = tryOrNull( + onError = { Timber.e(it, "Failed to initialize FileLogger") } + ) { + FileHandler( + cacheDirectory.absolutePath + "/" + fileNamePrefix + ".%g.txt", + maxLogSizeByte, + logRotationCount + ) + .also { it.formatter = LogFormatter() } + .also { logger.addHandler(it) } + } + } + + fun reset() { + // Delete all files + getLogFiles().map { + it.safeDelete() + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + fileHandler ?: return + GlobalScope.launch(dispatcher) { + if (skipLog(priority)) return@launch + if (t != null) { + logToFile(t) + } + logToFile(prioPrefixes[priority] ?: "$priority ", tag ?: "Tag", message) + } + } + + private fun skipLog(priority: Int): Boolean { + /* + return if (vectorPreferences.labAllowedExtendedLogging()) { + false + } else { + // Exclude verbose logs + priority < Log.DEBUG + } + */ + // Exclude verbose logs + return priority < Log.DEBUG + } + + /** + * Adds our own log files to the provided list of files. + * + * @return The list of files with logs. + */ + fun getLogFiles(): List<File> { + return tryOrNull( + onError = { Timber.e(it, "## getLogFiles() failed") } + ) { + fileHandler + ?.flush() + ?.let { 0 until logRotationCount } + ?.mapNotNull { index -> + File(cacheDirectory, "$fileNamePrefix.${index}.txt") + .takeIf { it.exists() } + } + } + .orEmpty() + } + + /** + * Log an Throwable. + * + * @param throwable the throwable to log + */ + private fun logToFile(throwable: Throwable?) { + throwable ?: return + + val errors = StringWriter() + throwable.printStackTrace(PrintWriter(errors)) + + logger.info(errors.toString()) + } + + private fun logToFile(level: String, tag: String, content: String) { + val b = StringBuilder() + b.append(Thread.currentThread().id) + b.append(" ") + b.append(level) + b.append("/") + b.append(tag) + b.append(": ") + b.append(content) + logger.info(b.toString()) + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt new file mode 100644 index 0000000000..82a411ec95 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.features.rageshake.api.rageshake.RageShake +import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore +import io.element.android.libraries.di.AppScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultRageshakePreferencesPresenter @Inject constructor( + private val rageshake: RageShake, + private val rageshakeDataStore: RageshakeDataStore, +) : RageshakePreferencesPresenter { + + @Composable + override fun present(): RageshakePreferencesState { + val localCoroutineScope = rememberCoroutineScope() + val isSupported: MutableState<Boolean> = rememberSaveable { + mutableStateOf(rageshake.isAvailable()) + } + val isEnabled = rageshakeDataStore + .isEnabled() + .collectAsState(initial = false) + + val sensitivity = rageshakeDataStore + .sensitivity() + .collectAsState(initial = 0f) + + fun handleEvents(event: RageshakePreferencesEvents) { + when (event) { + is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled) + is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity) + } + } + + return RageshakePreferencesState( + isEnabled = isEnabled.value, + isSupported = isSupported.value, + sensitivity = sensitivity.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.setSensitivity(sensitivity: Float) = launch { + rageshakeDataStore.setSensitivity(sensitivity) + } + + private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch { + rageshakeDataStore.setIsEnabled(enabled) + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt new file mode 100644 index 0000000000..1d9ff6ee5a --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.rageshake + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager +import androidx.core.content.getSystemService +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.seismic.ShakeDetector +import io.element.android.features.rageshake.api.rageshake.RageShake +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(scope = AppScope::class, boundType = RageShake::class) +class DefaultRageShake @Inject constructor( + @ApplicationContext context: Context, +) : ShakeDetector.Listener, RageShake { + + private var sensorManager = context.getSystemService<SensorManager>() + private var shakeDetector: ShakeDetector? = null + private var interceptor: (() -> Unit)? = null + + override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor + } + + /** + * Check if the feature is available on this device. + */ + override fun isAvailable(): Boolean { + return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + } + + override fun start(sensitivity: Float) { + sensorManager?.let { + shakeDetector = ShakeDetector(this).apply { + start(it, SensorManager.SENSOR_DELAY_GAME) + } + setSensitivity(sensitivity) + } + } + + override fun stop() { + shakeDetector?.stop() + } + + /** + * sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to + * [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)]. + */ + override fun setSensitivity(sensitivity: Float) { + shakeDetector?.setSensitivity( + ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt() + ) + } + + override fun hearShake() { + interceptor?.invoke() + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt new file mode 100644 index 0000000000..aa8965fed9 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.rageshake + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake") + +private val enabledKey = booleanPreferencesKey("enabled") +private val sensitivityKey = floatPreferencesKey("sensitivity") + +@ContributesBinding(AppScope::class) +class PreferencesRageshakeDataStore @Inject constructor( + @ApplicationContext context: Context +) : RageshakeDataStore { + private val store = context.dataStore + + override fun isEnabled(): Flow<Boolean> { + return store.data.map { prefs -> + prefs[enabledKey].orFalse() + } + } + + override suspend fun setIsEnabled(isEnabled: Boolean) { + store.edit { prefs -> + prefs[enabledKey] = isEnabled + } + } + + override fun sensitivity(): Flow<Float> { + return store.data.map { prefs -> + prefs[sensitivityKey] ?: 0.5f + } + } + + override suspend fun setSensitivity(sensitivity: Float) { + store.edit { prefs -> + prefs[sensitivityKey] = sensitivity + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt new file mode 100755 index 0000000000..5695596650 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.reporter + +import android.content.Context +import android.os.Build +import androidx.core.net.toFile +import androidx.core.net.toUri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.crash.CrashDataStore +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.api.reporter.ReportType +import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder +import io.element.android.features.rageshake.impl.R +import io.element.android.features.rageshake.impl.logs.VectorFileLogger +import io.element.android.libraries.androidutils.file.compressFile +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.toOnOff +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.util.Locale +import javax.inject.Inject +import javax.inject.Provider + +/** + * BugReporter creates and sends the bug reports. + */ +@ContributesBinding(AppScope::class) +class DefaultBugReporter @Inject constructor( + @ApplicationContext private val context: Context, + private val screenshotHolder: ScreenshotHolder, + private val crashDataStore: CrashDataStore, + private val coroutineDispatchers: CoroutineDispatchers, + private val okHttpClient: Provider<OkHttpClient>, + private val userAgentProvider: UserAgentProvider, + private val sessionStore: SessionStore, + private val buildMeta: BuildMeta, + /* + private val versionProvider: VersionProvider, + private val vectorPreferences: VectorPreferences, + private val vectorFileLogger: VectorFileLogger, + private val systemLocaleProvider: SystemLocaleProvider, + private val matrix: Matrix, + private val processInfo: ProcessInfo, + private val sdkIntProvider: BuildVersionSdkIntProvider, + private val vectorLocale: VectorLocaleProvider, + */ +) : BugReporter { + var inMultiWindowMode = false + + companion object { + // filenames + private const val LOG_CAT_ERROR_FILENAME = "logcatError.log" + private const val LOG_CAT_FILENAME = "logcat.log" + private const val KEY_REQUESTS_FILENAME = "keyRequests.log" + + private const val BUFFER_SIZE = 1024 * 1024 * 50 + } + + // the pending bug report call + private var mBugReportCall: Call? = null + + // boolean to cancel the bug report + private val mIsCancelled = false + + /* + val adapter = MatrixJsonParser.getMoshi() + .adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + */ + + private val LOGCAT_CMD_ERROR = arrayOf( + "logcat", // /< Run 'logcat' command + "-d", // /< Dump the log rather than continue outputting it + "-v", // formatting + "threadtime", // include timestamps + "AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging + "libcommunicator:V " + // /< All libcommunicator logging + "DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc) + "*:S" // /< Everything else silent, so don't pick it.. + ) + + private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") + + /** + * Send a bug report. + * + * @param reportType The report type (bug, suggestion, feedback) + * @param withDevicesLogs true to include the device log + * @param withCrashLogs true to include the crash logs + * @param withKeyRequestHistory true to include the crash logs + * @param withScreenshot true to include the screenshot + * @param theBugDescription the bug description + * @param serverVersion version of the server + * @param canContact true if the user opt in to be contacted directly + * @param customFields fields which will be sent with the report + * @param listener the listener + */ + override suspend fun sendBugReport( + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean, + customFields: Map<String, String>?, + listener: BugReporterListener? + ) { + // enumerate files to delete + val mBugReportFiles: MutableList<File> = ArrayList() + + var serverError: String? = null + var reportURL: String? = null + withContext(coroutineDispatchers.io) { + var bugDescription = theBugDescription + val crashCallStack = crashDataStore.crashInfo().first() + + if (crashCallStack.isNotEmpty() && withCrashLogs) { + bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" + bugDescription += crashCallStack + } + + val gzippedFiles = ArrayList<File>() + + val vectorFileLogger = VectorFileLogger.getFromTimber() + if (withDevicesLogs && vectorFileLogger != null) { + val files = vectorFileLogger.getLogFiles() + files.mapNotNullTo(gzippedFiles) { f -> + if (!mIsCancelled) { + compressFile(f) + } else { + null + } + } + } + + if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { + val gzippedLogcat = saveLogCat(false) + + if (null != gzippedLogcat) { + if (gzippedFiles.size == 0) { + gzippedFiles.add(gzippedLogcat) + } else { + gzippedFiles.add(0, gzippedLogcat) + } + } + } + + /* + activeSessionHolder.getSafeActiveSession() + ?.takeIf { !mIsCancelled && withKeyRequestHistory } + ?.cryptoService() + ?.getGossipingEvents() + ?.let { GossipingEventsSerializer().serialize(it) } + ?.toByteArray() + ?.let { rawByteArray -> + File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME) + .also { + it.outputStream() + .use { os -> os.write(rawByteArray) } + } + } + ?.let { compressFile(it) } + ?.let { gzippedFiles.add(it) } + */ + + val sessionData = sessionStore.getLatestSession() + val deviceId = sessionData?.deviceId ?: "undefined" + val userId = sessionData?.userId ?: "undefined" + var olmVersion = "undefined" + + if (!mIsCancelled) { + val text = when (reportType) { + ReportType.BUG_REPORT -> bugDescription + ReportType.SUGGESTION -> "[Suggestion] $bugDescription" + ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription" + ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription" + ReportType.AUTO_UISI_SENDER, + ReportType.AUTO_UISI -> bugDescription + } + + // build the multi part request + val builder = BugReporterMultipartBody.Builder() + .addFormDataPart("text", text) + .addFormDataPart("app", rageShakeAppNameForReport(reportType)) + .addFormDataPart("user_agent", userAgentProvider.provide()) + .addFormDataPart("user_id", userId) + .addFormDataPart("can_contact", canContact.toString()) + .addFormDataPart("device_id", deviceId) + // .addFormDataPart("version", versionProvider.getVersion(longFormat = true)) + // .addFormDataPart("branch_name", buildMeta.gitBranchName) + // .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) + .addFormDataPart("olm_version", olmVersion) + .addFormDataPart("device", Build.MODEL.trim()) + // .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff()) + .addFormDataPart("multi_window", inMultiWindowMode.toOnOff()) + // .addFormDataPart( + // "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " + + // Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME + // ) + .addFormDataPart("locale", Locale.getDefault().toString()) + // .addFormDataPart("app_language", vectorLocale.applicationLocale.toString()) + // .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString()) + // .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context)) + .addFormDataPart("server_version", serverVersion) + .apply { + customFields?.forEach { (name, value) -> + addFormDataPart(name, value) + } + } + + // add the gzipped files + for (file in gzippedFiles) { + builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())) + } + + mBugReportFiles.addAll(gzippedFiles) + + if (withScreenshot) { + screenshotHolder.getFileUri() + ?.toUri() + ?.toFile() + ?.let { screenshotFile -> + try { + builder.addFormDataPart( + "file", + screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) + ) + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : fail to write screenshot") + } + } + } + + // add some github labels + builder.addFormDataPart("label", buildMeta.versionName) + // builder.addFormDataPart("label", buildMeta.flavorDescription) + // builder.addFormDataPart("label", buildMeta.gitBranchName) + + // Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release". + // builder.addFormDataPart("label", BuildConfig.BUILD_TYPE) + + when (reportType) { + ReportType.BUG_REPORT -> { + /* nop */ + } + ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]") + ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback") + ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback") + ReportType.AUTO_UISI -> { + builder.addFormDataPart("label", "Z-UISI") + builder.addFormDataPart("label", "android") + builder.addFormDataPart("label", "uisi-recipient") + } + ReportType.AUTO_UISI_SENDER -> { + builder.addFormDataPart("label", "Z-UISI") + builder.addFormDataPart("label", "android") + builder.addFormDataPart("label", "uisi-sender") + } + } + + if (crashCallStack.isNotEmpty() && withCrashLogs) { + builder.addFormDataPart("label", "crash") + } + + val requestBody = builder.build() + + // add a progress listener + requestBody.setWriteListener { totalWritten, contentLength -> + val percentage = if (-1L != contentLength) { + if (totalWritten > contentLength) { + 100 + } else { + (totalWritten * 100 / contentLength).toInt() + } + } else { + 0 + } + + if (mIsCancelled && null != mBugReportCall) { + mBugReportCall!!.cancel() + } + + Timber.v("## onWrite() : $percentage%") + try { + listener?.onProgress(percentage) + } catch (e: Exception) { + Timber.e(e, "## onProgress() : failed") + } + } + + // build the request + val request = Request.Builder() + .url(context.getString(R.string.bug_report_url)) + .post(requestBody) + .build() + + var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR + var response: Response? = null + var errorMessage: String? = null + + // trigger the request + try { + mBugReportCall = okHttpClient.get().newCall(request) + response = mBugReportCall!!.execute() + responseCode = response.code + } catch (e: Exception) { + Timber.e(e, "response") + errorMessage = e.localizedMessage + } + + // if the upload failed, try to retrieve the reason + if (responseCode != HttpURLConnection.HTTP_OK) { + if (null != errorMessage) { + serverError = "Failed with error $errorMessage" + } else if (response?.body == null) { + serverError = "Failed with error $responseCode" + } else { + try { + val inputStream = response.body!!.byteStream() + + serverError = inputStream.use { + buildString { + var ch = it.read() + while (ch != -1) { + append(ch.toChar()) + ch = it.read() + } + } + } + + // check if the error message + serverError?.let { + try { + val responseJSON = JSONObject(it) + serverError = responseJSON.getString("error") + } catch (e: JSONException) { + Timber.e(e, "doInBackground ; Json conversion failed") + } + } + + // should never happen + if (null == serverError) { + serverError = "Failed with error $responseCode" + } + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : failed to parse error") + } + } + } else { + /* + reportURL = response?.body?.string()?.let { stringBody -> + adapter.fromJson(stringBody)?.get("report_url")?.toString() + } + */ + } + } + } + + withContext(coroutineDispatchers.main) { + mBugReportCall = null + + // delete when the bug report has been successfully sent + for (file in mBugReportFiles) { + file.safeDelete() + } + + if (null != listener) { + try { + if (mIsCancelled) { + listener.onUploadCancelled() + } else if (null == serverError) { + listener.onUploadSucceed(reportURL) + } else { + listener.onUploadFailed(serverError) + } + } catch (e: Exception) { + Timber.e(e, "## onPostExecute() : failed") + } + } + } + } + + /** + * Send a bug report either with email or with Vector. + */ + /* TODO Remove + fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) { + screenshot = takeScreenshot(activity) + logDbInfo() + logProcessInfo() + logOtherInfo() + activity.startActivity(BugReportActivity.intent(activity, reportType)) + } + */ + + // private fun logOtherInfo() { + // Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState()) + // } + + // private fun logDbInfo() { + // val dbInfo = matrix.debugService().getDbUsageInfo() + // Timber.i(dbInfo) + // } + + // private fun logProcessInfo() { + // val pInfo = processInfo.getInfo() + // Timber.i(pInfo) + // } + + private fun rageShakeAppNameForReport(reportType: ReportType): String { + // As per https://github.com/matrix-org/rageshake + // app: Identifier for the application (eg 'riot-web'). + // Should correspond to a mapping configured in the configuration file for github issue reporting to work. + // (see R.string.bug_report_url for configured RS server) + return context.getString( + when (reportType) { + ReportType.AUTO_UISI_SENDER, + ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name + else -> R.string.bug_report_app_name + } + ) + } + + // ============================================================================================================== + // Logcat management + // ============================================================================================================== + + /** + * Save the logcat. + * + * @param isErrorLogcat true to save the error logcat + * @return the file if the operation succeeds + */ + private fun saveLogCat(isErrorLogcat: Boolean): File? { + val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME) + + if (logCatErrFile.exists()) { + logCatErrFile.safeDelete() + } + + try { + logCatErrFile.writer().use { + getLogCatError(it, isErrorLogcat) + } + + return compressFile(logCatErrFile) + } catch (error: OutOfMemoryError) { + Timber.e(error, "## saveLogCat() : fail to write logcat OOM") + } catch (e: Exception) { + Timber.e(e, "## saveLogCat() : fail to write logcat") + } + + return null + } + + /** + * Retrieves the logs. + * + * @param streamWriter the stream writer + * @param isErrorLogCat true to save the error logs + */ + private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) { + val logcatProc: Process + + try { + logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG) + } catch (e1: IOException) { + return + } + + try { + val separator = System.getProperty("line.separator") + logcatProc.inputStream + .reader() + .buffered(BUFFER_SIZE) + .forEachLine { line -> + streamWriter.append(line) + streamWriter.append(separator) + } + } catch (e: IOException) { + Timber.e(e, "getLog fails") + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt new file mode 100644 index 0000000000..d61c7c8c01 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.screenshot + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.net.toUri +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder +import io.element.android.libraries.androidutils.bitmap.writeBitmap +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import java.io.File +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultScreenshotHolder @Inject constructor( + @ApplicationContext private val context: Context, +) : ScreenshotHolder { + private val file = File(context.filesDir, "screenshot.png") + + override fun writeBitmap(data: Bitmap) { + file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85) + } + + override fun getFileUri(): String? { + return file + .takeIf { it.exists() && it.length() > 0 } + ?.toUri() + ?.toString() + } + + override fun reset() { + file.safeDelete() + } +} diff --git a/features/rageshake/impl/src/main/res/values-cs/translations.xml b/features/rageshake/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..d95752e91c --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Připojit snímek obrazovky"</string> + <string name="screen_bug_report_contact_me">"V případě dalších dotazů se na mě můžete obrátit"</string> + <string name="screen_bug_report_contact_me_title">"Kontaktujte mě"</string> + <string name="screen_bug_report_edit_screenshot">"Upravit snímek obrazovky"</string> + <string name="screen_bug_report_editor_description">"Popište prosím chybu. Co jste udělali? Co jste očekávali, že se stane? Co se ve skutečnosti stalo? Uveďte co nejvíce podrobností."</string> + <string name="screen_bug_report_editor_placeholder">"Popište chybu…"</string> + <string name="screen_bug_report_editor_supporting">"Pokud je to možné, prosím, napište popis anglicky."</string> + <string name="screen_bug_report_include_crash_logs">"Odeslat záznamy o selhání"</string> + <string name="screen_bug_report_include_logs">"Povolit protokoly"</string> + <string name="screen_bug_report_include_screenshot">"Odeslat snímek obrazovky"</string> + <string name="screen_bug_report_logs_description">"Protokoly budou součástí vaší zprávy, aby se zajistilo že vše funguje správně. Chcete-li odeslat zprávu bez protokolů, vypněte toto nastavení."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..a24318545d --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Bildschirmfoto anhängen"</string> + <string name="screen_bug_report_contact_me">"Sie können mich kontaktieren, wenn Sie weitere Fragen haben"</string> + <string name="screen_bug_report_contact_me_title">"Kontaktiere mich"</string> + <string name="screen_bug_report_edit_screenshot">"Bildschirmfoto bearbeiten"</string> + <string name="screen_bug_report_editor_description">"Beschreibe bitte den Fehler. Was hast du gemacht? Was hätte passieren sollen? Was ist passiert? Bitte beschreibe alles mit so vielen Details wie möglich."</string> + <string name="screen_bug_report_editor_placeholder">"Beschreibe den Fehler…"</string> + <string name="screen_bug_report_editor_supporting">"Wenn möglich, verfassen Sie die Beschreibung bitte auf Englisch."</string> + <string name="screen_bug_report_include_crash_logs">"Absturzprotokolle senden"</string> + <string name="screen_bug_report_include_logs">"Senden Sie Protokolle, um zu helfen"</string> + <string name="screen_bug_report_include_screenshot">"Bildschirmfoto senden"</string> + <string name="screen_bug_report_logs_description">"Um zu überprüfen, ob alles wie vorgesehen funktioniert, werden Protokolle mit deiner Nachricht gesendet. Diese werden privat sein. Um nur Ihre Nachricht zu senden, schalte diese Einstellung aus."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..4191f67596 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Adjuntar captura de pantalla"</string> + <string name="screen_bug_report_contact_me">"Podéis poneros en contacto conmigo para resolver dudas relacionadas"</string> + <string name="screen_bug_report_edit_screenshot">"Editar captura de pantalla"</string> + <string name="screen_bug_report_editor_description">"Describe el problema. ¿Qué hiciste? ¿Qué esperabas que ocurriera? ¿Qué ocurrió en realidad? Por favor, detállalo todo lo que puedas."</string> + <string name="screen_bug_report_editor_placeholder">"Describe el error…"</string> + <string name="screen_bug_report_editor_supporting">"Si es posible, escriba la descripción en inglés."</string> + <string name="screen_bug_report_include_crash_logs">"Enviar registros de fallos"</string> + <string name="screen_bug_report_include_logs">"Enviar registros para ayudar"</string> + <string name="screen_bug_report_include_screenshot">"Enviar captura de pantalla"</string> + <string name="screen_bug_report_logs_description">"Para comprobar que todo funciona correctamente, se enviarán registros de fallos con su mensaje. Serán privados. Para enviar sólo tu mensaje, desactiva esta opción."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..bf6ad2d215 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Joindre une capture d\'écran"</string> + <string name="screen_bug_report_contact_me">"Vous pouvez me contacter si vous avez des questions complémentaires"</string> + <string name="screen_bug_report_contact_me_title">"Me contacter"</string> + <string name="screen_bug_report_edit_screenshot">"Modifier la capture d\'écran"</string> + <string name="screen_bug_report_editor_description">"S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible."</string> + <string name="screen_bug_report_editor_placeholder">"Décrire le bogue"</string> + <string name="screen_bug_report_editor_supporting">"Si possible, veuillez rédiger la description en anglais."</string> + <string name="screen_bug_report_include_crash_logs">"Envoyer des journaux d’incident"</string> + <string name="screen_bug_report_include_logs">"Autoriser à inclure les journaux techniques"</string> + <string name="screen_bug_report_include_screenshot">"Envoyer une capture d’écran"</string> + <string name="screen_bug_report_logs_description">"Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour l’envoyer sans ces journaux, désactivez ce paramètre."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-it/translations.xml b/features/rageshake/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..2c95849db0 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Allega istantanea schermo"</string> + <string name="screen_bug_report_contact_me">"Potete contattarmi per qualsiasi altra domanda"</string> + <string name="screen_bug_report_edit_screenshot">"Modifica istantanea schermo"</string> + <string name="screen_bug_report_editor_description">"Descrivi il bug. Che cosa hai fatto? Cosa ti aspettavi che accadesse? Cosa è effettivamente accaduto. Si prega di inserire il maggior numero di dettagli possibile."</string> + <string name="screen_bug_report_editor_placeholder">"Descrivi il problema…"</string> + <string name="screen_bug_report_editor_supporting">"Se possibile, scrivere la descrizione in inglese."</string> + <string name="screen_bug_report_include_crash_logs">"Invia i log degli arresti anomali"</string> + <string name="screen_bug_report_include_logs">"Invia i log per aiutarci"</string> + <string name="screen_bug_report_include_screenshot">"Invia istantanea schermo"</string> + <string name="screen_bug_report_logs_description">"Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Questi saranno privati. Per inviare solo il tuo messaggio, disattiva questa impostazione."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..db0398c0db --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Atașați o captură de ecran"</string> + <string name="screen_bug_report_contact_me">"Puteți să mă contactați dacă aveți întrebări suplimentare"</string> + <string name="screen_bug_report_edit_screenshot">"Editați captura de ecran"</string> + <string name="screen_bug_report_editor_description">"Vă rugăm să descrieți eroarea. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să intrați în cât mai multe detalii cu putință."</string> + <string name="screen_bug_report_editor_placeholder">"Descrieți eroarea…"</string> + <string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string> + <string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string> + <string name="screen_bug_report_include_logs">"Trimiteți log-uri pentru a ajuta"</string> + <string name="screen_bug_report_include_screenshot">"Trimiteți captură de ecran"</string> + <string name="screen_bug_report_logs_description">"Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values-sk/translations.xml b/features/rageshake/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..cb530d1712 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Priložiť snímku obrazovky"</string> + <string name="screen_bug_report_contact_me">"V prípade ďalších otázok ma môžete kontaktovať"</string> + <string name="screen_bug_report_contact_me_title">"Kontaktujte ma"</string> + <string name="screen_bug_report_edit_screenshot">"Upraviť snímku obrazovky"</string> + <string name="screen_bug_report_editor_description">"Popíšte prosím chybu. Čo ste urobili? Čo ste očakávali, že sa stane? Čo sa skutočne stalo. Prosím, uveďte čo najviac podrobností."</string> + <string name="screen_bug_report_editor_placeholder">"Popíšte chybu…"</string> + <string name="screen_bug_report_editor_supporting">"Ak je to možné, napíšte popis v angličtine."</string> + <string name="screen_bug_report_include_crash_logs">"Odoslať záznamy o zlyhaní"</string> + <string name="screen_bug_report_include_logs">"Povoliť záznamy"</string> + <string name="screen_bug_report_include_screenshot">"Odoslať snímku obrazovky"</string> + <string name="screen_bug_report_logs_description">"K vašej správe budú priložené záznamy o chybe, aby sme sa uistili, že všetko funguje správne. Ak chcete odoslať správu bez záznamov o chybe, vypnite toto nastavenie."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values/localazy.xml b/features/rageshake/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..fb02c93780 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_bug_report_attach_screenshot">"Attach screenshot"</string> + <string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions."</string> + <string name="screen_bug_report_contact_me_title">"Contact me"</string> + <string name="screen_bug_report_edit_screenshot">"Edit screenshot"</string> + <string name="screen_bug_report_editor_description">"Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can."</string> + <string name="screen_bug_report_editor_placeholder">"Describe the bug…"</string> + <string name="screen_bug_report_editor_supporting">"If possible, please write the description in English."</string> + <string name="screen_bug_report_include_crash_logs">"Send crash logs"</string> + <string name="screen_bug_report_include_logs">"Allow logs"</string> + <string name="screen_bug_report_include_screenshot">"Send screenshot"</string> + <string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string> + <string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string> +</resources> diff --git a/features/rageshake/impl/src/main/res/values/strings.xml b/features/rageshake/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000000..48eff44d57 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <!-- Rageshake configuration --> + <string name="bug_report_url" translatable="false">https://riot.im/bugreports/submit</string> + <string name="bug_report_app_name" translatable="false">element-x-android</string> + <string name="bug_report_auto_uisi_app_name" translatable="false">element-auto-uisi</string> + +</resources> diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt new file mode 100644 index 0000000000..9b868a637d --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.test.crash.A_CRASH_DATA +import io.element.android.features.rageshake.test.crash.FakeCrashDataStore +import io.element.android.features.rageshake.test.screenshot.A_SCREENSHOT_URI +import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import kotlinx.coroutines.test.runTest +import org.junit.Test + +const val A_SHORT_DESCRIPTION = "bug!" +const val A_LONG_DESCRIPTION = "I have seen a bug!" + +class BugReportPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasCrashLogs).isFalse() + assertThat(initialState.formState).isEqualTo(BugReportFormState.Default) + assertThat(initialState.sending).isEqualTo(Async.Uninitialized) + assertThat(initialState.screenshotUri).isNull() + assertThat(initialState.sendingProgress).isEqualTo(0f) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - set description`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isFalse() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isTrue() + } + } + + @Test + fun `present - can contact`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetCanContact(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true)) + initialState.eventSink.invoke(BugReportEvents.SetCanContact(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = false)) + } + } + + @Test + fun `present - send logs`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Since this is true by default, start by disabling + initialState.eventSink.invoke(BugReportEvents.SetSendLog(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = false)) + initialState.eventSink.invoke(BugReportEvents.SetSendLog(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = true)) + } + } + + @Test + fun `present - send screenshot`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true)) + initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = false)) + } + } + + @Test + fun `present - reset all`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasCrashLogs).isTrue() + assertThat(initialState.screenshotUri).isEqualTo(A_SCREENSHOT_URI) + initialState.eventSink.invoke(BugReportEvents.ResetAll) + val resetState = awaitItem() + assertThat(resetState.hasCrashLogs).isFalse() + // TODO Make it live assertThat(resetState.screenshotUri).isNull() + } + } + + @Test + fun `present - send success`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Success), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(progressState.submitEnabled).isFalse() + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + assertThat(awaitItem().sendingProgress).isEqualTo(1f) + skipItems(1) + assertThat(awaitItem().sending).isEqualTo(Async.Success(Unit)) + } + } + + @Test + fun `present - send failure`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Failure), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Failure + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON) + // Reset failure + initialState.eventSink.invoke(BugReportEvents.ClearError) + val lastItem = awaitItem() + assertThat(lastItem.sendingProgress).isEqualTo(0f) + assertThat(lastItem.sending).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `present - send cancel`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Cancel), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Cancelled + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sending).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt new file mode 100644 index 0000000000..ac8940a1ac --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.bugreport + +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.api.reporter.ReportType +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import kotlinx.coroutines.delay + +class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { + override suspend fun sendBugReport( + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean, + customFields: Map<String, String>?, + listener: BugReporterListener?, + ) { + delay(100) + listener?.onProgress(0) + delay(100) + listener?.onProgress(50) + delay(100) + when (mode) { + FakeBugReporterMode.Success -> Unit + FakeBugReporterMode.Failure -> { + listener?.onUploadFailed(A_FAILURE_REASON) + return + } + FakeBugReporterMode.Cancel -> { + listener?.onUploadCancelled() + return + } + } + listener?.onProgress(100) + delay(100) + listener?.onUploadSucceed(null) + } +} + +enum class FakeBugReporterMode { + Success, + Failure, + Cancel +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt new file mode 100644 index 0000000000..2d9834607f --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.crash.ui + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.api.crash.CrashDetectionEvents +import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter +import io.element.android.features.rageshake.test.crash.A_CRASH_DATA +import io.element.android.features.rageshake.test.crash.FakeCrashDataStore +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CrashDetectionPresenterTest { + @Test + fun `present - initial state no crash`() = runTest { + val presenter = DefaultCrashDetectionPresenter( + FakeCrashDataStore() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetected).isFalse() + } + } + + @Test + fun `present - initial state crash`() = runTest { + val presenter = DefaultCrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + + } + } + + @Test + fun `present - reset app has crashed`() = runTest { + val presenter = DefaultCrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + initialState.eventSink.invoke(CrashDetectionEvents.ResetAppHasCrashed) + assertThat(awaitItem().crashDetected).isFalse() + } + } + + @Test + fun `present - reset all crash data`() = runTest { + val presenter = DefaultCrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData) + assertThat(awaitItem().crashDetected).isFalse() + } + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt new file mode 100644 index 0000000000..eb49eb450e --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.detection + +import android.graphics.Bitmap +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents +import io.element.android.features.rageshake.api.screenshot.ImageResult +import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter +import io.element.android.features.rageshake.test.rageshake.FakeRageShake +import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore +import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass +import org.junit.Test + +class RageshakeDetectionPresenterTest { + + companion object { + private lateinit var aBitmap: Bitmap + + @BeforeClass + @JvmStatic + fun initBitmap() { + aBitmap = mockk() + } + } + + @Test + fun `present - initial state`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.takeScreenshot).isFalse() + assertThat(initialState.showDialog).isFalse() + assertThat(initialState.isStarted).isFalse() + } + } + + @Test + fun `present - start and stop detection`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.StopDetection) + assertThat(awaitItem().isStarted).isFalse() + } + } + + @Test + fun `present - screenshot with success then dismiss`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) + val finalState = awaitItem() + assertThat(finalState.showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isTrue() + } + } + + @Test + fun `present - screenshot with error then dismiss`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION)) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) + val finalState = awaitItem() + assertThat(finalState.showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isTrue() + } + } + + @Test + fun `present - screenshot then disable`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = DefaultRageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = DefaultRageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) + skipItems(1) + assertThat(awaitItem().showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isFalse() + } + } +} + diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt new file mode 100644 index 0000000000..b01ce22645 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.impl.preferences + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents +import io.element.android.features.rageshake.test.rageshake.A_SENSITIVITY +import io.element.android.features.rageshake.test.rageshake.FakeRageShake +import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RageshakePreferencesPresenterTest { + @Test + fun `present - initial state available`() = runTest { + val presenter = DefaultRageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSupported).isTrue() + assertThat(initialState.isEnabled).isTrue() + } + } + + @Test + fun `present - initial state not available`() = runTest { + val presenter = DefaultRageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = false), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSupported).isFalse() + assertThat(initialState.isEnabled).isTrue() + } + } + + @Test + fun `present - enable and disable`() = runTest { + val presenter = DefaultRageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnabled).isTrue() + initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(false)) + assertThat(awaitItem().isEnabled).isFalse() + initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(true)) + assertThat(awaitItem().isEnabled).isTrue() + } + } + + @Test + fun `present - set sensitivity`() = runTest { + val presenter = DefaultRageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY) + initialState.eventSink.invoke(RageshakePreferencesEvents.SetSensitivity(A_SENSITIVITY + 1f)) + assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f) + } + } +} + diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts new file mode 100644 index 0000000000..31d0377f35 --- /dev/null +++ b/features/rageshake/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.rageshake.test" +} + +dependencies { + implementation(projects.features.rageshake.api) + implementation(libs.coroutines.core) +} diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt new file mode 100644 index 0000000000..e12db31763 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/crash/FakeCrashDataStore.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.test.crash + +import io.element.android.features.rageshake.api.crash.CrashDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_CRASH_DATA = "Some crash data" + +class FakeCrashDataStore( + crashData: String = "", + appHasCrashed: Boolean = false, +) : CrashDataStore { + private val appHasCrashedFlow = MutableStateFlow(appHasCrashed) + private val crashDataFlow = MutableStateFlow(crashData) + + override fun setCrashData(crashData: String) { + crashDataFlow.value = crashData + } + + override suspend fun resetAppHasCrashed() { + appHasCrashedFlow.value = false + } + + override fun appHasCrashed(): Flow<Boolean> = appHasCrashedFlow + + override fun crashInfo(): Flow<String> = crashDataFlow + + override suspend fun reset() { + appHasCrashedFlow.value = false + crashDataFlow.value = "" + } +} diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt new file mode 100644 index 0000000000..d127c360cf --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageShake.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.test.rageshake + +import io.element.android.features.rageshake.api.rageshake.RageShake + +class FakeRageShake( + private var isAvailableValue: Boolean = true +) : RageShake { + + private var interceptor: (() -> Unit)? = null + + override fun isAvailable() = isAvailableValue + + override fun start(sensitivity: Float) { + } + + override fun stop() { + } + + override fun setSensitivity(sensitivity: Float) { + } + + override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor + } + + fun triggerPhoneRageshake() = interceptor?.invoke() +} diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt new file mode 100644 index 0000000000..698e0a3cd8 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/rageshake/FakeRageshakeDataStore.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.test.rageshake + +import io.element.android.features.rageshake.api.rageshake.RageshakeDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_SENSITIVITY = 1f + +class FakeRageshakeDataStore( + isEnabled: Boolean = false, + sensitivity: Float = A_SENSITIVITY, +) : RageshakeDataStore { + + private val isEnabledFlow = MutableStateFlow(isEnabled) + override fun isEnabled(): Flow<Boolean> = isEnabledFlow + + override suspend fun setIsEnabled(isEnabled: Boolean) { + isEnabledFlow.value = isEnabled + } + + private val sensitivityFlow = MutableStateFlow(sensitivity) + override fun sensitivity(): Flow<Float> = sensitivityFlow + + override suspend fun setSensitivity(sensitivity: Float) { + sensitivityFlow.value = sensitivity + } + + override suspend fun reset() = Unit +} diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt new file mode 100644 index 0000000000..5e45960b28 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/screenshot/FakeScreenshotHolder.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.rageshake.test.screenshot + +import android.graphics.Bitmap +import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder + +const val A_SCREENSHOT_URI = "file://content/uri" + +class FakeScreenshotHolder(private val screenshotUri: String? = null) : ScreenshotHolder { + override fun writeBitmap(data: Bitmap) = Unit + + override fun getFileUri() = screenshotUri + + override fun reset() = Unit +} diff --git a/features/roomdetails/api/.gitignore b/features/roomdetails/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/roomdetails/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/roomdetails/api/build.gradle.kts b/features/roomdetails/api/build.gradle.kts new file mode 100644 index 0000000000..ddc062cb3b --- /dev/null +++ b/features/roomdetails/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdetails.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt new file mode 100644 index 0000000000..e73d63f38c --- /dev/null +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.api + +import android.os.Parcelable +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize + +interface RoomDetailsEntryPoint : FeatureEntryPoint { + + sealed interface InitialTarget : Parcelable { + @Parcelize + object RoomDetails : InitialTarget + + @Parcelize + data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget + } + + data class Inputs(val initialElement: InitialTarget) : NodeInputs + + fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs, plugins: List<Plugin>): Node +} diff --git a/features/roomdetails/impl/.gitignore b/features/roomdetails/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/roomdetails/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts new file mode 100644 index 0000000000..0965cf484a --- /dev/null +++ b/features/roomdetails/impl/build.gradle.kts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdetails.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + api(projects.features.roomdetails.api) + api(projects.libraries.usersearch.api) + api(projects.services.apperror.api) + implementation(libs.coil.compose) + implementation(projects.features.leaveroom.api) + implementation(projects.services.analytics.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.mockk) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.usersearch.test) + testImplementation(projects.tests.testutils) + testImplementation(projects.features.leaveroom.fake) + + ksp(libs.showkase.processor) +} diff --git a/features/roomdetails/impl/consumer-rules.pro b/features/roomdetails/impl/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt new file mode 100644 index 0000000000..be6b915212 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint.InitialTarget +import io.element.android.features.roomdetails.impl.RoomDetailsFlowNode.NavTarget +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: RoomDetailsEntryPoint.Inputs, + plugins: List<Plugin> + ): Node { + return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins + inputs) + } +} + +internal fun InitialTarget.toNavTarget() = when (this) { + is InitialTarget.RoomDetails -> NavTarget.RoomDetails + is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsAction.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsAction.kt new file mode 100644 index 0000000000..61b3da21f9 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsAction.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +sealed interface RoomDetailsAction { + object Edit : RoomDetailsAction + + object AddTopic : RoomDetailsAction +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt new file mode 100644 index 0000000000..b7bb31757e --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +sealed interface RoomDetailsEvent { + object LeaveRoom : RoomDetailsEvent +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt new file mode 100644 index 0000000000..7298e0eda6 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode +import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode +import io.element.android.features.roomdetails.impl.members.RoomMemberListNode +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +class RoomDetailsFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, +) : BackstackNode<RoomDetailsFlowNode.NavTarget>( + backstack = BackStack( + initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Inputs>().first().initialElement.toNavTarget(), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object RoomDetails : NavTarget + + @Parcelize + object RoomMemberList : NavTarget + + @Parcelize + object RoomDetailsEdit : NavTarget + + @Parcelize + object InviteMembers : NavTarget + + @Parcelize + data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.RoomDetails -> { + val roomDetailsCallback = object : RoomDetailsNode.Callback { + override fun openRoomMemberList() { + backstack.push(NavTarget.RoomMemberList) + } + + override fun editRoomDetails() { + backstack.push(NavTarget.RoomDetailsEdit) + } + + override fun openInviteMembers() { + backstack.push(NavTarget.InviteMembers) + } + } + createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) + } + + NavTarget.RoomMemberList -> { + val roomMemberListCallback = object : RoomMemberListNode.Callback { + override fun openRoomMemberDetails(roomMemberId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(roomMemberId)) + } + + override fun openInviteMembers() { + backstack.push(NavTarget.InviteMembers) + } + } + createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) + } + + NavTarget.RoomDetailsEdit -> { + createNode<RoomDetailsEditNode>(buildContext) + } + + NavTarget.InviteMembers -> { + createNode<RoomInviteMembersNode>(buildContext) + } + + is NavTarget.RoomMemberDetails -> { + val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId)) + createNode<RoomMemberDetailsNode>(buildContext, plugins) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt new file mode 100644 index 0000000000..b74cf7aaf1 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.services.analytics.api.AnalyticsService +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesNode(RoomScope::class) +class RoomDetailsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: RoomDetailsPresenter, + private val room: MatrixRoom, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun openRoomMemberList() + fun openInviteMembers() + fun editRoomDetails() + } + + private val callbacks = plugins<Callback>() + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomDetails)) + } + ) + } + + private fun openRoomMemberList() { + callbacks.forEach { it.openRoomMemberList() } + } + + private fun invitePeople() { + callbacks.forEach { it.openInviteMembers() } + } + + private fun onShareRoom(context: Context) { + val alias = room.alias ?: room.alternativeAliases.firstOrNull() + val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } + ?: PermalinkBuilder.permalinkForRoomId(room.roomId) + permalinkResult.onSuccess { permalink -> + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = context.getString(R.string.screen_room_details_share_room_title), + text = permalink, + noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } + + private fun onShareMember(context: Context, member: RoomMember) { + val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId) + permalinkResult.onSuccess { permalink -> + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = context.getString(R.string.screen_room_details_share_room_title), + text = permalink, + noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } + + private fun onEditRoomDetails() { + callbacks.forEach { it.editRoomDetails() } + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + val state = presenter.present() + + fun onShareRoom() { + this.onShareRoom(context) + } + + fun onShareMember(roomMember: RoomMember) { + this.onShareMember(context, roomMember) + } + + fun onActionClicked(action: RoomDetailsAction) { + when (action) { + RoomDetailsAction.Edit -> onEditRoomDetails() + RoomDetailsAction.AddTopic -> onEditRoomDetails() + } + } + + RoomDetailsView( + state = state, + modifier = modifier, + goBack = this::navigateUp, + onActionClicked = ::onActionClicked, + onShareRoom = ::onShareRoom, + onShareMember = ::onShareMember, + openRoomMemberList = ::openRoomMemberList, + invitePeople = ::invitePeople, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt new file mode 100644 index 0000000000..2612c24365 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.canInvite +import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.ui.room.getDirectRoomMember +import javax.inject.Inject + +class RoomDetailsPresenter @Inject constructor( + private val room: MatrixRoom, + private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, + private val leaveRoomPresenter: LeaveRoomPresenter, +) : Presenter<RoomDetailsState> { + + @Composable + override fun present(): RoomDetailsState { + val leaveRoomState = leaveRoomPresenter.present() + LaunchedEffect(Unit) { + room.updateMembers() + } + + val membersState by room.membersStateFlow.collectAsState() + val canInvite by getCanInvite(membersState) + val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME) + val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR) + val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) + val dmMember by room.getDirectRoomMember(membersState) + val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) + val roomType by getRoomType(dmMember) + + val topicState = remember(canEditTopic, room.topic, roomType) { + val topic = room.topic + + when { + !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) + canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic + else -> RoomTopicState.Hidden + } + } + + fun handleEvents(event: RoomDetailsEvent) { + when (event) { + is RoomDetailsEvent.LeaveRoom -> + leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId)) + } + } + + val roomMemberDetailsState = roomMemberDetailsPresenter?.present() + + return RoomDetailsState( + roomId = room.roomId.value, + roomName = room.displayName, + roomAlias = room.alias, + roomAvatarUrl = room.avatarUrl, + roomTopic = topicState, + memberCount = room.joinedMemberCount, + isEncrypted = room.isEncrypted, + canInvite = canInvite, + canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, + roomType = roomType, + roomMemberDetailsState = roomMemberDetailsState, + leaveRoomState = leaveRoomState, + eventSink = ::handleEvents, + ) + } + + @Composable + private fun roomMemberDetailsPresenter(dmMemberState: RoomMember?) = remember(dmMemberState) { + dmMemberState?.let { roomMember -> + roomMembersDetailsPresenterFactory.create(roomMember.userId) + } + } + + @Composable + private fun getRoomType(dmMember: RoomMember?): State<RoomDetailsType> = remember(dmMember) { + derivedStateOf { + if (dmMember != null) { + RoomDetailsType.Dm(dmMember) + } else { + RoomDetailsType.Room + } + } + } + + @Composable + private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) { + value = room.canInvite().getOrElse { false } + } + + @Composable + private fun getCanSendState(membersState: MatrixRoomMembersState, type: StateEventType) = produceState(false, membersState) { + value = room.canSendState(type).getOrElse { false } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt new file mode 100644 index 0000000000..f146181bb6 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState +import io.element.android.libraries.matrix.api.room.RoomMember + +data class RoomDetailsState( + val roomId: String, + val roomName: String, + val roomAlias: String?, + val roomAvatarUrl: String?, + val roomTopic: RoomTopicState, + val memberCount: Long, + val isEncrypted: Boolean, + val roomType: RoomDetailsType, + val roomMemberDetailsState: RoomMemberDetailsState?, + val canEdit: Boolean, + val canInvite: Boolean, + val leaveRoomState: LeaveRoomState, + val eventSink: (RoomDetailsEvent) -> Unit +) + +sealed interface RoomDetailsType { + object Room : RoomDetailsType + data class Dm(val roomMember: RoomMember) : RoomDetailsType +} + +sealed interface RoomTopicState { + object Hidden : RoomTopicState + object CanAddTopic : RoomTopicState + data class ExistingTopic(val topic: String) : RoomTopicState +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt new file mode 100644 index 0000000000..cc4c4a6b1b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState + +open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> { + override val values: Sequence<RoomDetailsState> + get() = sequenceOf( + aRoomDetailsState(), + aRoomDetailsState().copy(roomTopic = RoomTopicState.Hidden), + aRoomDetailsState().copy(roomTopic = RoomTopicState.CanAddTopic), + aRoomDetailsState().copy(isEncrypted = false), + aRoomDetailsState().copy(roomAlias = null), + aDmRoomDetailsState().copy(roomName = "Daniel"), + aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), + aRoomDetailsState().copy(canInvite = true), + aRoomDetailsState().copy(canEdit = true), + // Add other state here + ) +} + +fun aDmRoomMember( + userId: UserId = UserId("@daniel:domain.com"), + displayName: String? = "Daniel", + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0, + normalizedPowerLevel: Long = powerLevel, + isIgnored: Boolean = false, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, +) + +fun aRoomDetailsState() = RoomDetailsState( + roomId = "a room id", + roomName = "Marketing", + roomAlias = "#marketing:domain.com", + roomAvatarUrl = null, + roomTopic = RoomTopicState.ExistingTopic( + "Welcome to #marketing, home of the Marketing team " + + "|| WIKI PAGE: https://domain.org/wiki/Marketing " + + "|| MAIL iki/Marketing " + + "|| MAI iki/Marketing " + + "|| MAI iki/Marketing..." + ), + memberCount = 32, + isEncrypted = true, + canInvite = false, + canEdit = false, + roomType = RoomDetailsType.Room, + roomMemberDetailsState = null, + leaveRoomState = LeaveRoomState(), + eventSink = {} +) + +fun aDmRoomDetailsState(isDmMemberIgnored: Boolean = false) = aRoomDetailsState().copy( + roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)), roomMemberDetailsState = aRoomMemberDetailsState() +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt new file mode 100644 index 0000000000..9aa8ea41c3 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.PersonAddAlt +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.leaveroom.api.LeaveRoomView +import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs +import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection +import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection +import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.MainActionButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.LargeHeightPreview +import io.element.android.libraries.designsystem.theme.components.DropdownMenu +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RoomDetailsView( + state: RoomDetailsState, + goBack: () -> Unit, + onActionClicked: (RoomDetailsAction) -> Unit, + onShareRoom: () -> Unit, + onShareMember: (RoomMember) -> Unit, + openRoomMemberList: () -> Unit, + invitePeople: () -> Unit, + modifier: Modifier = Modifier, +) { + fun onShareMember() { + onShareMember((state.roomType as RoomDetailsType.Dm).roomMember) + } + + Scaffold( + modifier = modifier, + topBar = { + RoomDetailsTopBar( + goBack = goBack, + showEdit = state.canEdit, + onActionClicked = onActionClicked + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding) + ) { + LeaveRoomView(state = state.leaveRoomState) + + when (state.roomType) { + RoomDetailsType.Room -> { + RoomHeaderSection( + avatarUrl = state.roomAvatarUrl, + roomId = state.roomId, + roomName = state.roomName, + roomAlias = state.roomAlias + ) + MainActionsSection(onShareRoom = onShareRoom) + } + + is RoomDetailsType.Dm -> { + val member = state.roomType.roomMember + RoomMemberHeaderSection( + avatarUrl = state.roomAvatarUrl ?: member.avatarUrl, + userId = member.userId.value, + userName = state.roomName + ) + RoomMemberMainActionsSection(onShareUser = ::onShareMember) + } + } + Spacer(Modifier.height(18.dp)) + + if (state.roomTopic !is RoomTopicState.Hidden) { + TopicSection( + roomTopic = state.roomTopic, + onActionClicked = onActionClicked, + ) + } + + if (state.roomType is RoomDetailsType.Room) { + MembersSection( + memberCount = state.memberCount, + openRoomMemberList = openRoomMemberList, + ) + + if (state.canInvite) { + InviteSection( + invitePeople = invitePeople + ) + } + } + + if (state.isEncrypted) { + SecuritySection() + } + + if (state.roomType is RoomDetailsType.Dm && state.roomMemberDetailsState != null) { + val roomMemberState = state.roomMemberDetailsState + BlockUserSection(roomMemberState) + BlockUserDialogs(roomMemberState) + } + + OtherActionsSection(onLeaveRoom = { + state.eventSink(RoomDetailsEvent.LeaveRoom) + }) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RoomDetailsTopBar( + goBack: () -> Unit, + onActionClicked: (RoomDetailsAction) -> Unit, + showEdit: Boolean, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + TopAppBar( + modifier = modifier, + title = { }, + navigationIcon = { BackButton(onClick = goBack) }, + actions = { + if (showEdit) { + IconButton(onClick = { showMenu = !showMenu }) { + Icon(Icons.Default.MoreVert, "") + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_edit)) }, + onClick = { + // Explicitly close the menu before handling the action, as otherwise it stays open during the + // transition and renders really badly. + showMenu = false + onActionClicked(RoomDetailsAction.Edit) + }, + ) + } + } + }, + ) +} + +@Composable +internal fun MainActionsSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + MainActionButton(title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, onClick = onShareRoom) + } +} + +@Composable +internal fun RoomHeaderSection( + avatarUrl: String?, + roomId: String, + roomName: String, + roomAlias: String?, + modifier: Modifier = Modifier +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(70.dp)) { + Avatar( + avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader), + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Text(roomName, style = ElementTheme.typography.fontHeadingLgBold) + if (roomAlias != null) { + Spacer(modifier = Modifier.height(6.dp)) + Text(roomAlias, style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary) + } + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +internal fun TopicSection( + roomTopic: RoomTopicState, + onActionClicked: (RoomDetailsAction) -> Unit, + modifier: Modifier = Modifier +) { + PreferenceCategory(title = stringResource(CommonStrings.common_topic), modifier = modifier) { + if (roomTopic is RoomTopicState.CanAddTopic) { + PreferenceText( + title = stringResource(R.string.screen_room_details_add_topic_title), + icon = Icons.Outlined.Add, + onClick = { onActionClicked(RoomDetailsAction.AddTopic) }, + ) + } else if (roomTopic is RoomTopicState.ExistingTopic) { + Text( + roomTopic.topic, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.tertiary + ) + } + } +} + +@Composable +internal fun MembersSection( + memberCount: Long, + openRoomMemberList: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(R.string.screen_room_details_people_title), + icon = Icons.Outlined.Person, + currentValue = memberCount.toString(), + onClick = openRoomMemberList, + ) + } +} + +@Composable +internal fun InviteSection( + invitePeople: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(R.string.screen_room_details_invite_people_title), + icon = Icons.Outlined.PersonAddAlt, + onClick = invitePeople, + ) + } +} + +@Composable +internal fun SecuritySection(modifier: Modifier = Modifier) { + PreferenceCategory(title = stringResource(R.string.screen_room_details_security_title), modifier = modifier) { + PreferenceText( + title = stringResource(R.string.screen_room_details_encryption_enabled_title), + subtitle = stringResource(R.string.screen_room_details_encryption_enabled_subtitle), + icon = Icons.Outlined.Lock, + ) + } +} + +@Composable +internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(showDivider = false, modifier = modifier) { + PreferenceText( + title = stringResource(R.string.screen_room_details_leave_room_title), + icon = ImageVector.vectorResource(R.drawable.ic_door_open), + tintColor = MaterialTheme.colorScheme.error, + onClick = onLeaveRoom, + ) + } +} + +@LargeHeightPreview +@Composable +fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = + ElementPreviewLight { ContentToPreview(state) } + +@LargeHeightPreview +@Composable +fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomDetailsState) { + RoomDetailsView( + state = state, + goBack = {}, + onActionClicked = {}, + onShareRoom = {}, + onShareMember = {}, + openRoomMemberList = {}, + invitePeople = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt new file mode 100644 index 0000000000..cccf682c9c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.blockuser + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.roomdetails.impl.R +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun BlockUserSection(state: RoomMemberDetailsState, modifier: Modifier = Modifier) { + PreferenceCategory(showDivider = false, modifier = modifier) { + when (state.isBlocked) { + is Async.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink) + is Async.Loading -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = true, eventSink = state.eventSink) + is Async.Success -> PreferenceBlockUser(isBlocked = state.isBlocked.data, isLoading = false, eventSink = state.eventSink) + Async.Uninitialized -> PreferenceBlockUser(isBlocked = null, isLoading = true, eventSink = state.eventSink) + } + } + if (state.isBlocked is Async.Failure) { + RetryDialog( + content = stringResource(CommonStrings.error_unknown), + onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearBlockUserError) }, + onRetry = { + val event = when (state.isBlocked.prevData) { + true -> RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false) + false -> RoomMemberDetailsEvents.BlockUser(needsConfirmation = false) + null -> /*Should not happen */ RoomMemberDetailsEvents.ClearBlockUserError + } + state.eventSink(event) + }, + ) + } +} + +@Composable +private fun PreferenceBlockUser( + isBlocked: Boolean?, + isLoading: Boolean, + eventSink: (RoomMemberDetailsEvents) -> Unit, + modifier: Modifier = Modifier, +) { + if (isBlocked.orFalse()) { + PreferenceText( + title = stringResource(R.string.screen_dm_details_unblock_user), + icon = Icons.Outlined.Block, + onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) }, + loadingCurrentValue = isLoading, + modifier = modifier, + ) + } else { + PreferenceText( + title = stringResource(R.string.screen_dm_details_block_user), + icon = Icons.Outlined.Block, + tintColor = MaterialTheme.colorScheme.error, + onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) }, + loadingCurrentValue = isLoading, + modifier = modifier, + ) + } +} + +@Composable +internal fun BlockUserDialogs(state: RoomMemberDetailsState) { + when (state.displayConfirmationDialog) { + null -> Unit + RoomMemberDetailsState.ConfirmationDialog.Block -> { + BlockConfirmationDialog( + onBlockAction = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) }, + onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) } + ) + } + RoomMemberDetailsState.ConfirmationDialog.Unblock -> { + UnblockConfirmationDialog( + onUnblockAction = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)) }, + onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) } + ) + } + } +} + +@Composable +internal fun BlockConfirmationDialog(onBlockAction: () -> Unit, onDismiss: () -> Unit) { + ConfirmationDialog( + title = stringResource(R.string.screen_dm_details_block_user), + content = stringResource(R.string.screen_dm_details_block_alert_description), + submitText = stringResource(R.string.screen_dm_details_block_alert_action), + onSubmitClicked = onBlockAction, + onDismiss = onDismiss + ) +} + +@Composable +internal fun UnblockConfirmationDialog(onUnblockAction: () -> Unit, onDismiss: () -> Unit) { + ConfirmationDialog( + title = stringResource(R.string.screen_dm_details_unblock_user), + content = stringResource(R.string.screen_dm_details_unblock_alert_description), + submitText = stringResource(R.string.screen_dm_details_unblock_alert_action), + onSubmitClicked = onUnblockAction, + onDismiss = onDismiss + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt new file mode 100644 index 0000000000..ca462c6507 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom + +@Module +@ContributesTo(RoomScope::class) +object RoomMemberModule { + + @Provides + fun provideRoomMemberDetailsPresenterFactory( + matrixClient: MatrixClient, + room: MatrixRoom, + ): RoomMemberDetailsPresenter.Factory { + return object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter(matrixClient, room, roomMemberId) + } + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt new file mode 100644 index 0000000000..b4bc348b8a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.edit + +import io.element.android.libraries.matrix.ui.media.AvatarAction + +sealed interface RoomDetailsEditEvents { + data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents + data class UpdateRoomName(val name: String) : RoomDetailsEditEvents + data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents + object Save : RoomDetailsEditEvents + object CancelSaveChanges : RoomDetailsEditEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt new file mode 100644 index 0000000000..e81cd84c24 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.edit + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +class RoomDetailsEditNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: RoomDetailsEditPresenter, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomSettings)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomDetailsEditView( + state = state, + onBackPressed = ::navigateUp, + onRoomEdited = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt new file mode 100644 index 0000000000..0024c64268 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.edit + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +class RoomDetailsEditPresenter @Inject constructor( + private val room: MatrixRoom, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, +) : Presenter<RoomDetailsEditState> { + + @Composable + override fun present(): RoomDetailsEditState { + val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() + + // Since there is no way to obtain the new avatar uri after uploading a new avatar, + // just erase the local value when the room field has changed + var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) } + + var roomName by rememberSaveable { mutableStateOf((room.name ?: room.displayName).trim()) } + var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) } + + val saveButtonEnabled by remember( + roomSyncUpdateFlow.value, + roomName, + roomTopic, + roomAvatarUri, + ) { + derivedStateOf { + roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim() + || roomName.trim() != (room.name ?: room.displayName).trim() + || roomTopic.orEmpty().trim() != room.topic.orEmpty().trim() + } + } + + var canChangeName by remember { mutableStateOf(false) } + var canChangeTopic by remember { mutableStateOf(false) } + var canChangeAvatar by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false } + canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false } + canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false } + } + + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> if (uri != null) roomAvatarUri = uri } + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> if (uri != null) roomAvatarUri = uri } + ) + + val avatarActions by remember(roomAvatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { roomAvatarUri != null }, + ).toImmutableList() + } + } + + val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } + val localCoroutineScope = rememberCoroutineScope() + fun handleEvents(event: RoomDetailsEditEvents) { + when (event) { + is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(roomName, roomTopic, roomAvatarUri, saveAction) + is RoomDetailsEditEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.Remove -> roomAvatarUri = null + } + } + + is RoomDetailsEditEvents.UpdateRoomName -> roomName = event.name + is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopic = event.topic.takeUnless { it.isEmpty() } + RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized + } + } + + return RoomDetailsEditState( + roomId = room.roomId.value, + roomName = roomName, + canChangeName = canChangeName, + roomTopic = roomTopic.orEmpty(), + canChangeTopic = canChangeTopic, + roomAvatarUrl = roomAvatarUri, + canChangeAvatar = canChangeAvatar, + avatarActions = avatarActions, + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction.value, + eventSink = ::handleEvents, + ) + } + + private fun CoroutineScope.saveChanges(name: String, topic: String?, avatarUri: Uri?, action: MutableState<Async<Unit>>) = launch { + val results = mutableListOf<Result<Unit>>() + suspend { + if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { + results.add(room.setTopic(topic.orEmpty()).onFailure { + Timber.e(it, "Failed to set room topic") + }) + } + if (name.isNotEmpty() && name.trim() != room.name.orEmpty().trim()) { + results.add(room.setName(name).onFailure { + Timber.e(it, "Failed to set room name") + }) + } + if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) { + results.add(updateAvatar(avatarUri).onFailure { + Timber.e(it, "Failed to update avatar") + }) + } + if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() + }.runCatchingUpdatingState(action) + } + + private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> { + return runCatching { + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() + room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() + } else { + room.removeAvatar().getOrThrow() + } + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt new file mode 100644 index 0000000000..ceb87b6f27 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.edit + +import android.net.Uri +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.architecture.Async +import kotlinx.collections.immutable.ImmutableList + +data class RoomDetailsEditState( + val roomId: String, + val roomName: String, + val canChangeName: Boolean, + val roomTopic: String, + val canChangeTopic: Boolean, + val roomAvatarUrl: Uri?, + val canChangeAvatar: Boolean, + val avatarActions: ImmutableList<AvatarAction>, + val saveButtonEnabled: Boolean, + val saveAction: Async<Unit>, + val eventSink: (RoomDetailsEditEvents) -> Unit +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt new file mode 100644 index 0000000000..96fd47c381 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.edit + +import android.net.Uri +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import kotlinx.collections.immutable.persistentListOf + +open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> { + override val values: Sequence<RoomDetailsEditState> + get() = sequenceOf( + aRoomDetailsEditState(), + aRoomDetailsEditState().copy(roomTopic = ""), + aRoomDetailsEditState().copy(roomAvatarUrl = Uri.parse("example://uri")), + aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false), + aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), + aRoomDetailsEditState().copy(saveAction = Async.Loading()), + aRoomDetailsEditState().copy(saveAction = Async.Failure(Throwable("Whelp"))) + ) +} + +fun aRoomDetailsEditState() = RoomDetailsEditState( + roomId = "a room id", + roomName = "Marketing", + canChangeName = true, + roomTopic = "a room topic that is quite long so should wrap onto multiple lines", + canChangeTopic = true, + roomAvatarUrl = null, + canChangeAvatar = true, + avatarActions = persistentListOf(), + saveButtonEnabled = true, + saveAction = Async.Uninitialized, + eventSink = {} +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt new file mode 100644 index 0000000000..029f5ac5df --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) + +package io.element.android.features.roomdetails.impl.edit + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddAPhoto +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.LabelledTextField +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet +import io.element.android.libraries.matrix.ui.components.UnsavedAvatar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun RoomDetailsEditView( + state: RoomDetailsEditState, + onBackPressed: () -> Unit, + onRoomEdited: () -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val itemActionsBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + + fun onAvatarClicked() { + focusManager.clearFocus() + coroutineScope.launch { + itemActionsBottomSheetState.show() + } + } + + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.screen_room_details_edit_room_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + enabled = state.saveButtonEnabled, + onClick = { + focusManager.clearFocus() + state.eventSink(RoomDetailsEditEvents.Save) + }, + ) { + Text( + text = stringResource(CommonStrings.action_save), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + EditableAvatarView(state, ::onAvatarClicked) + Spacer(modifier = Modifier.height(60.dp)) + + if (state.canChangeName) { + LabelledTextField( + label = stringResource(id = R.string.screen_room_details_room_name_label), + value = state.roomName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, + ) + } else { + LabelledReadOnlyField( + title = stringResource(R.string.screen_room_details_room_name_label), + value = state.roomName + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + if (state.canChangeTopic) { + LabelledTextField( + label = stringResource(CommonStrings.common_topic), + value = state.roomTopic, + placeholder = stringResource(CommonStrings.common_topic_placeholder), + maxLines = 10, + onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) }, + ) + } else { + LabelledReadOnlyField( + title = stringResource(R.string.screen_room_details_topic_title), + value = state.roomTopic + ) + } + } + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + modalBottomSheetState = itemActionsBottomSheetState, + onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } + ) + + when (state.saveAction) { + is Async.Loading -> { + ProgressDialog(text = stringResource(R.string.screen_room_details_updating_room)) + } + + is Async.Failure -> { + ErrorDialog( + content = stringResource(R.string.screen_room_details_edition_error), + onDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) }, + ) + } + + is Async.Success -> { + LaunchedEffect(state.saveAction) { + onRoomEdited() + } + } + + else -> Unit + } +} + +@Composable +private fun EditableAvatarView( + state: RoomDetailsEditState, + onAvatarClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(70.dp) + .clickable(onClick = onAvatarClicked, enabled = state.canChangeAvatar) + ) { + // TODO this might be able to be simplified into a single component once send/receive media is done + when (state.roomAvatarUrl?.scheme) { + null, "mxc" -> { + Avatar( + avatarData = AvatarData(state.roomId, state.roomName, state.roomAvatarUrl?.toString(), size = AvatarSize.RoomHeader), + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + UnsavedAvatar( + avatarUri = state.roomAvatarUrl, + modifier = Modifier.fillMaxSize(), + ) + } + } + + if (state.canChangeAvatar) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Outlined.AddAPhoto, + contentDescription = "", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} + +@Composable +private fun LabelledReadOnlyField( + title: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.primary, + text = title, + ) + + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + text = value, + ) + } +} + +private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = + pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } + +@Preview +@Composable +fun RoomDetailsEditViewLightPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomDetailsEditViewDarkPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomDetailsEditState) { + RoomDetailsEditView( + state = state, + onBackPressed = {}, + onRoomEdited = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersEvents.kt new file mode 100644 index 0000000000..80be60fb8c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface RoomInviteMembersEvents { + data class ToggleUser(val user: MatrixUser) : RoomInviteMembersEvents + data class UpdateSearchQuery(val query: String) : RoomInviteMembersEvents + data class OnSearchActiveChanged(val active: Boolean) : RoomInviteMembersEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt new file mode 100644 index 0000000000..37646aedfc --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.apperror.api.AppErrorStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +@ContributesNode(RoomScope::class) +class RoomInviteMembersNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + coroutineDispatchers: CoroutineDispatchers, + private val room: MatrixRoom, + private val presenter: RoomInviteMembersPresenter, + private val appErrorStateService: AppErrorStateService, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io) + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Invites)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current.applicationContext + + RoomInviteMembersView( + state = state, + modifier = modifier, + onBackPressed = { navigateUp() }, + onSendPressed = { users -> + navigateUp() + + coroutineScope.launch { + val anyInviteFailed = users + .map { room.inviteUserById(it.userId) } + .any { it.isFailure } + + if (anyInviteFailed) { + appErrorStateService.showError( + title = context.getString(CommonStrings.common_unable_to_invite_title), + body = context.getString(CommonStrings.common_unable_to_invite_message), + ) + } + + room.updateMembers() + } + } + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenter.kt new file mode 100644 index 0000000000..00cb04b118 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenter.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserRepository +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class RoomInviteMembersPresenter @Inject constructor( + private val userRepository: UserRepository, + private val roomMemberListDataSource: RoomMemberListDataSource, + private val coroutineDispatchers: CoroutineDispatchers, +) : Presenter<RoomInviteMembersState> { + + @Composable + override fun present(): RoomInviteMembersState { + val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) } + val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) } + val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.NotSearching()) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + fetchMembers(roomMembers) + } + + LaunchedEffect(searchQuery, roomMembers) { + performSearch(searchResults, roomMembers, selectedUsers, searchQuery) + } + + return RoomInviteMembersState( + canInvite = selectedUsers.value.isNotEmpty(), + selectedUsers = selectedUsers.value, + searchQuery = searchQuery, + isSearchActive = searchActive, + searchResults = searchResults.value, + eventSink = { + when (it) { + is RoomInviteMembersEvents.OnSearchActiveChanged -> { + searchActive = it.active + searchQuery = "" + } + + is RoomInviteMembersEvents.UpdateSearchQuery -> { + searchQuery = it.query + } + + is RoomInviteMembersEvents.ToggleUser -> { + selectedUsers.toggleUser(it.user) + searchResults.toggleUser(it.user) + } + } + } + ) + } + + @JvmName("toggleUserInSelectedUsers") + private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) { + value = if (value.contains(user)) { + value.filterNot { it == user } + } else { + (value + user) + }.toImmutableList() + } + + @JvmName("toggleUserInSearchResults") + private fun MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>.toggleUser(user: MatrixUser) { + val existingResults = value + if (existingResults is SearchBarResultState.Results) { + value = SearchBarResultState.Results( + existingResults.results.map { iu -> + if (iu.matrixUser == user) { + iu.copy(isSelected = !iu.isSelected) + } else { + iu + } + }.toImmutableList() + ) + } + } + + private suspend fun performSearch( + searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>, + roomMembers: MutableState<Async<ImmutableList<RoomMember>>>, + selectedUsers: MutableState<ImmutableList<MatrixUser>>, + searchQuery: String, + ) = withContext(coroutineDispatchers.io) { + searchResults.value = SearchBarResultState.NotSearching() + + val joinedMembers = roomMembers.value.dataOrNull().orEmpty() + + userRepository.search(searchQuery).collect { + searchResults.value = when { + it.isEmpty() -> SearchBarResultState.NoResults() + else -> SearchBarResultState.Results(it.map { result -> + val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership + val isJoined = existingMembership == RoomMembershipState.JOIN + val isInvited = existingMembership == RoomMembershipState.INVITE + InvitableUser( + matrixUser = result.matrixUser, + isSelected = selectedUsers.value.contains(result.matrixUser) || isJoined || isInvited, + isAlreadyJoined = isJoined, + isAlreadyInvited = isInvited, + isUnresolved = result.isUnresolved, + ) + }.toImmutableList()) + } + } + } + + private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) { + suspend { + withContext(coroutineDispatchers.io) { + roomMemberListDataSource.search("").toImmutableList() + } + }.runCatchingUpdatingState(roomMembers) + } +} + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersState.kt new file mode 100644 index 0000000000..9a2ceb7c4b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersState.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class RoomInviteMembersState( + val canInvite: Boolean = false, + val searchQuery: String = "", + val searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.NotSearching(), + val selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), + val isSearchActive: Boolean = false, + val eventSink: (RoomInviteMembersEvents) -> Unit = {}, +) + +data class InvitableUser( + val matrixUser: MatrixUser, + val isSelected: Boolean = false, + val isAlreadyJoined: Boolean = false, + val isAlreadyInvited: Boolean = false, + val isUnresolved: Boolean = false, +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersStateProvider.kt new file mode 100644 index 0000000000..00e9496c2a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersStateProvider.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInviteMembersState> { + override val values: Sequence<RoomInviteMembersState> + get() = sequenceOf( + RoomInviteMembersState(), + RoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()), + RoomInviteMembersState(isSearchActive = true, searchQuery = "some query"), + RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()), + RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResults()), + RoomInviteMembersState( + isSearchActive = true, + canInvite = true, + searchQuery = "some query", + selectedUsers = persistentListOf( + aMatrixUser("@carol:server.org", "Carol") + ), + searchResults = SearchBarResultState.Results( + persistentListOf( + InvitableUser(aMatrixUser("@alice:server.org")), + InvitableUser(aMatrixUser("@bob:server.org", "Bob")), + InvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true), + InvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true), + InvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true), + ) + ) + ), + RoomInviteMembersState( + isSearchActive = true, + canInvite = true, + searchQuery = "@alice:server.org", + selectedUsers = persistentListOf( + aMatrixUser("@carol:server.org", "Carol") + ), + searchResults = SearchBarResultState.Results( + persistentListOf( + InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true), + InvitableUser(aMatrixUser("@bob:server.org", "Bob")), + ) + ) + ), + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt new file mode 100644 index 0000000000..555f04af20 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow +import io.element.android.libraries.matrix.ui.components.CheckableUserRow +import io.element.android.libraries.matrix.ui.components.SelectedUsersList +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RoomInviteMembersView( + state: RoomInviteMembersState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onSendPressed: (List<MatrixUser>) -> Unit = {}, +) { + Scaffold( + topBar = { + RoomInviteMembersTopBar( + onBackPressed = { + if (state.isSearchActive) { + state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false)) + } else { + onBackPressed() + } + }, + onSendPressed = { onSendPressed(state.selectedUsers) }, + canSend = state.canInvite, + ) + } + ) { padding -> + Column( + modifier = modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RoomInviteMembersSearchBar( + modifier = Modifier.fillMaxWidth(), + query = state.searchQuery, + selectedUsers = state.selectedUsers, + state = state.searchResults, + active = state.isSearchActive, + onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) }, + onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) }, + onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + ) + + if (!state.isSearchActive) { + SelectedUsersList( + modifier = Modifier.fillMaxWidth(), + selectedUsers = state.selectedUsers, + autoScroll = true, + onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + contentPadding = PaddingValues(16.dp), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomInviteMembersTopBar( + canSend: Boolean, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onSendPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.screen_room_details_invite_people_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + onClick = onSendPressed, + content = { + Text(stringResource(CommonStrings.action_send)) + }, + enabled = canSend, + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomInviteMembersSearchBar( + query: String, + state: SearchBarResultState<ImmutableList<InvitableUser>>, + selectedUsers: ImmutableList<MatrixUser>, + active: Boolean, + modifier: Modifier = Modifier, + placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), + onActiveChanged: (Boolean) -> Unit = {}, + onTextChanged: (String) -> Unit = {}, + onUserToggled: (MatrixUser) -> Unit = {}, +) { + SearchBar( + query = query, + onQueryChange = onTextChanged, + active = active, + onActiveChange = onActiveChanged, + modifier = modifier, + placeHolderTitle = placeHolderTitle, + contentPrefix = { + if (selectedUsers.isNotEmpty()) { + SelectedUsersList( + modifier = Modifier.fillMaxWidth(), + selectedUsers = selectedUsers, + autoScroll = true, + onUserRemoved = onUserToggled, + contentPadding = PaddingValues(16.dp), + ) + } + }, + showBackButton = false, + resultState = state, + resultHandler = { results -> + Text( + text = stringResource(id = CommonStrings.common_search_results), + style = ElementTheme.typography.fontBodyLgMedium, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 8.dp) + ) + + LazyColumn { + itemsIndexed(results) { index, invitableUser -> + if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) { + CheckableUnresolvedUserRow( + checked = invitableUser.isSelected, + avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem), + id = invitableUser.matrixUser.userId.value, + onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, + modifier = Modifier.fillMaxWidth() + ) + } else { + CheckableUserRow( + checked = invitableUser.isSelected, + enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined, + avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem), + name = invitableUser.matrixUser.getBestName(), + subtext = when { + // If they're already invited or joined we show that information + invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member) + invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited) + // Otherwise show the ID, unless that's already used for their name + invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value + else -> null + }, + onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, + modifier = Modifier.fillMaxWidth() + ) + } + + if (index < results.lastIndex) { + Divider() + } + } + } + }, + ) +} + +@Preview +@Composable +fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomInviteMembersState) { + RoomInviteMembersView(state) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt new file mode 100644 index 0000000000..5a9c30698b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListDataSource.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class RoomMemberListDataSource @Inject constructor( + private val room: MatrixRoom, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) { + val roomMembers = room.membersStateFlow + .dropWhile { it !is MatrixRoomMembersState.Ready } + .first() + .roomMembers() + .orEmpty() + val filteredMembers = if (query.isBlank()) { + roomMembers + } else { + roomMembers.filter { member -> + member.userId.value.contains(query, ignoreCase = true) + || member.displayName?.contains(query, ignoreCase = true).orFalse() + } + } + filteredMembers + } + +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt new file mode 100644 index 0000000000..43716660eb --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +sealed interface RoomMemberListEvents { + data class UpdateSearchQuery(val query: String) : RoomMemberListEvents + data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt new file mode 100644 index 0000000000..6145ca79d8 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +class RoomMemberListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: RoomMemberListPresenter, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun openRoomMemberDetails(roomMemberId: UserId) + fun openInviteMembers() + } + + private val callbacks = plugins<Callback>() + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomMembers)) + } + ) + } + + private fun openRoomMemberDetails(roomMemberId: UserId) { + callbacks.forEach { + it.openRoomMemberDetails(roomMemberId) + } + } + + private fun openInviteMembers() { + callbacks.forEach { + it.openInviteMembers() + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomMemberListView( + state = state, + modifier = modifier, + onBackPressed = { navigateUp() }, + onMemberSelected = this::openRoomMemberDetails, + onInvitePressed = this::openInviteMembers, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt new file mode 100644 index 0000000000..0787563aed --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.canInvite +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class RoomMemberListPresenter @Inject constructor( + private val room: MatrixRoom, + private val roomMemberListDataSource: RoomMemberListDataSource, + private val coroutineDispatchers: CoroutineDispatchers, +) : Presenter<RoomMemberListState> { + + @Composable + override fun present(): RoomMemberListState { + var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchResults by remember { + mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.NotSearching()) + } + var isSearchActive by rememberSaveable { mutableStateOf(false) } + + val membersState by room.membersStateFlow.collectAsState() + val canInvite by produceState(initialValue = false, key1 = membersState) { + value = room.canInvite().getOrElse { false } + } + + LaunchedEffect(Unit) { + withContext(coroutineDispatchers.io) { + val members = roomMemberListDataSource.search("").groupBy { it.membership } + roomMembers = Async.Success( + RoomMembers( + invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), + joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), + ) + ) + } + } + + LaunchedEffect(searchQuery) { + withContext(coroutineDispatchers.io) { + searchResults = if (searchQuery.isEmpty()) { + SearchBarResultState.NotSearching() + } else { + val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership } + if (results.isEmpty()) SearchBarResultState.NoResults() + else SearchBarResultState.Results( + RoomMembers( + invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), + joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), + ) + ) + } + } + } + + return RoomMemberListState( + roomMembers = roomMembers, + searchQuery = searchQuery, + searchResults = searchResults, + isSearchActive = isSearchActive, + canInvite = canInvite, + eventSink = { event -> + when (event) { + is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active + is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query + } + }, + ) + } + +} + diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt new file mode 100644 index 0000000000..f718db703b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.collections.immutable.ImmutableList + +data class RoomMemberListState( + val roomMembers: Async<RoomMembers>, + val searchQuery: String, + val searchResults: SearchBarResultState<RoomMembers>, + val isSearchActive: Boolean, + val canInvite: Boolean, + val eventSink: (RoomMemberListEvents) -> Unit, +) + +data class RoomMembers( + val invited: ImmutableList<RoomMember>, + val joined: ImmutableList<RoomMember> +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt new file mode 100644 index 0000000000..f1e6447a10 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import kotlinx.collections.immutable.persistentListOf + +internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> { + override val values: Sequence<RoomMemberListState> + get() = sequenceOf( + aRoomMemberListState( + roomMembers = Async.Success( + RoomMembers( + invited = persistentListOf(aVictor(), aWalter()), + joined = persistentListOf(anAlice(), aBob()), + ) + ) + ), + aRoomMemberListState(roomMembers = Async.Loading()), + aRoomMemberListState().copy(canInvite = true), + aRoomMemberListState().copy(isSearchActive = false), + aRoomMemberListState().copy(isSearchActive = true), + aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"), + aRoomMemberListState().copy( + isSearchActive = true, + searchQuery = "@someone:matrix.org", + searchResults = SearchBarResultState.Results( + RoomMembers( + invited = persistentListOf(aVictor()), + joined = persistentListOf(anAlice()), + ) + ), + ), + aRoomMemberListState().copy( + isSearchActive = true, + searchQuery = "something-with-no-results", + searchResults = SearchBarResultState.NoResults() + ), + ) +} + +internal fun aRoomMemberListState( + roomMembers: Async<RoomMembers> = Async.Uninitialized, + searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.NotSearching(), +) = RoomMemberListState( + roomMembers = roomMembers, + searchQuery = "", + searchResults = searchResults, + isSearchActive = false, + canInvite = false, + eventSink = {} +) + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + normalizedPowerLevel: Long = 0L, + isIgnored: Boolean = false, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, +) + +fun aRoomMemberList() = listOf( + anAlice(), + aBob(), + aRoomMember(UserId("@carol:server.org"), "Carol"), + aRoomMember(UserId("@david:server.org"), "David"), + aRoomMember(UserId("@eve:server.org"), "Eve"), + aRoomMember(UserId("@justin:server.org"), "Justin"), + aRoomMember(UserId("@mallory:server.org"), "Mallory"), + aRoomMember(UserId("@susie:server.org"), "Susie"), + aVictor(), + aWalter(), +) + +fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice") +fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob") + +fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE) + +fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt new file mode 100644 index 0000000000..3bfde66c06 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RoomMemberListView( + state: RoomMemberListState, + onBackPressed: () -> Unit, + onInvitePressed: () -> Unit, + onMemberSelected: (UserId) -> Unit, + modifier: Modifier = Modifier, +) { + + fun onUserSelected(roomMember: RoomMember) { + onMemberSelected(roomMember.userId) + } + + Scaffold( + topBar = { + if (!state.isSearchActive) { + RoomMemberListTopBar( + canInvite = state.canInvite, + onBackPressed = onBackPressed, + onInvitePressed = onInvitePressed, + ) + } + } + ) { padding -> + Column( + modifier = modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RoomMemberSearchBar( + query = state.searchQuery, + state = state.searchResults, + active = state.isSearchActive, + placeHolderTitle = stringResource(CommonStrings.common_search_for_someone), + onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, + onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, + onUserSelected = ::onUserSelected, + modifier = Modifier.fillMaxWidth() + ) + + if (!state.isSearchActive) { + if (state.roomMembers is Async.Success) { + RoomMemberList( + roomMembers = state.roomMembers.data, + showMembersCount = true, + onUserSelected = ::onUserSelected + ) + } else if (state.roomMembers.isLoading()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } + } +} + +@Composable +private fun RoomMemberList( + roomMembers: RoomMembers, + showMembersCount: Boolean, + onUserSelected: (RoomMember) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { + if (roomMembers.invited.isNotEmpty()) { + roomMemberListSection( + headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) }, + members = roomMembers.invited, + onMemberSelected = { onUserSelected(it) } + ) + } + if (roomMembers.joined.isNotEmpty()) { + roomMemberListSection( + headerText = { + if (showMembersCount) { + val memberCount = roomMembers.joined.count() + pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) + } else { + stringResource(id = R.string.screen_room_member_list_room_members_header_title) + } + }, + members = roomMembers.joined, + onMemberSelected = { onUserSelected(it) } + ) + } + } +} + +private fun LazyListScope.roomMemberListSection( + headerText: @Composable () -> String, + members: ImmutableList<RoomMember>, + onMemberSelected: (RoomMember) -> Unit, +) { + item { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = headerText(), + style = ElementTheme.typography.fontBodyLgRegular, + color = MaterialTheme.colorScheme.secondary, + ) + } + items(members) { matrixUser -> + RoomMemberListItem( + modifier = Modifier.fillMaxWidth(), + roomMember = matrixUser, + onClick = { onMemberSelected(matrixUser) } + ) + } +} + +@Composable +private fun RoomMemberListItem( + roomMember: RoomMember, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + MatrixUserRow( + modifier = modifier.clickable(onClick = onClick), + matrixUser = MatrixUser( + userId = roomMember.userId, + displayName = roomMember.displayName, + avatarUrl = roomMember.avatarUrl + ), + avatarSize = AvatarSize.UserListItem, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomMemberListTopBar( + canInvite: Boolean, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onInvitePressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.screen_room_details_people_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + if (canInvite) { + TextButton( + modifier = Modifier.padding(horizontal = 8.dp), + onClick = onInvitePressed, + ) { + Text( + text = stringResource(CommonStrings.action_invite), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomMemberSearchBar( + query: String, + state: SearchBarResultState<RoomMembers>, + active: Boolean, + placeHolderTitle: String, + onActiveChanged: (Boolean) -> Unit, + onTextChanged: (String) -> Unit, + onUserSelected: (RoomMember) -> Unit, + modifier: Modifier = Modifier, +) { + SearchBar( + query = query, + onQueryChange = onTextChanged, + active = active, + onActiveChange = onActiveChanged, + modifier = modifier, + placeHolderTitle = placeHolderTitle, + resultState = state, + resultHandler = { results -> + RoomMemberList( + roomMembers = results, + showMembersCount = false, + onUserSelected = { onUserSelected(it) } + ) + }, + ) +} + +@Preview +@Composable +fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomMemberListState) { + RoomMemberListView( + state = state, + onBackPressed = {}, + onMemberSelected = {}, + onInvitePressed = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt new file mode 100644 index 0000000000..c09d9a1f70 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +sealed interface RoomMemberDetailsEvents { + data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents + data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents + object ClearBlockUserError : RoomMemberDetailsEvents + object ClearConfirmationDialog : RoomMemberDetailsEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt new file mode 100644 index 0000000000..54b86f973c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.services.analytics.api.AnalyticsService +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesNode(RoomScope::class) +class RoomMemberDetailsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val analyticsService: AnalyticsService, + presenterFactory: RoomMemberDetailsPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class RoomMemberDetailsInput( + val roomMemberId: UserId + ) : NodeInputs + + private val inputs = inputs<RoomMemberDetailsInput>() + private val presenter = presenterFactory.create(inputs.roomMemberId) + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + + fun onShareUser() { + val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId) + permalinkResult.onSuccess { permalink -> + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = context.getString(R.string.screen_room_details_share_room_title), + text = permalink, + noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } + + val state = presenter.present() + RoomMemberDetailsView( + state = state, + modifier = modifier, + goBack = this::navigateUp, + onShareUser = ::onShareUser + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt new file mode 100644 index 0000000000..3be83a2fef --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class RoomMemberDetailsPresenter @AssistedInject constructor( + private val client: MatrixClient, + private val room: MatrixRoom, + @Assisted private val roomMemberId: UserId, +) : Presenter<RoomMemberDetailsState> { + + interface Factory { + fun create(roomMemberId: UserId): RoomMemberDetailsPresenter + } + + @Composable + override fun present(): RoomMemberDetailsState { + val coroutineScope = rememberCoroutineScope() + var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) } + val roomMember by room.getRoomMemberAsState(roomMemberId) + // the room member is not really live... + val isBlocked: MutableState<Async<Boolean>> = remember(roomMember) { + val isIgnored = roomMember?.isIgnored + if (isIgnored == null) { + mutableStateOf(Async.Uninitialized) + } else { + mutableStateOf(Async.Success(isIgnored)) + } + } + LaunchedEffect(Unit) { + room.updateMembers() + } + + fun handleEvents(event: RoomMemberDetailsEvents) { + when (event) { + is RoomMemberDetailsEvents.BlockUser -> { + if (event.needsConfirmation) { + confirmationDialog = ConfirmationDialog.Block + } else { + confirmationDialog = null + coroutineScope.blockUser(roomMemberId, isBlocked) + } + } + is RoomMemberDetailsEvents.UnblockUser -> { + if (event.needsConfirmation) { + confirmationDialog = ConfirmationDialog.Unblock + } else { + confirmationDialog = null + coroutineScope.unblockUser(roomMemberId, isBlocked) + } + } + RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null + RoomMemberDetailsEvents.ClearBlockUserError -> { + isBlocked.value = Async.Success(isBlocked.value.dataOrNull().orFalse()) + } + } + } + + val userName by produceState(initialValue = roomMember?.displayName) { + room.userDisplayName(roomMemberId).onSuccess { displayName -> + if (displayName != null) value = displayName + } + } + + val userAvatar by produceState(initialValue = roomMember?.avatarUrl) { + room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl -> + if (avatarUrl != null) value = avatarUrl + } + } + + return RoomMemberDetailsState( + userId = roomMemberId.value, + userName = userName, + avatarUrl = userAvatar, + isBlocked = isBlocked.value, + displayConfirmationDialog = confirmationDialog, + isCurrentUser = client.isMe(roomMember?.userId), + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Async<Boolean>>) = launch { + isBlockedState.value = Async.Loading(false) + client.ignoreUser(userId) + .fold( + onSuccess = { + isBlockedState.value = Async.Success(true) + room.updateMembers() + }, + onFailure = { + isBlockedState.value = Async.Failure(it, false) + } + ) + } + + private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Async<Boolean>>) = launch { + isBlockedState.value = Async.Loading(true) + client.unignoreUser(userId) + .fold( + onSuccess = { + isBlockedState.value = Async.Success(false) + room.updateMembers() + }, + onFailure = { + isBlockedState.value = Async.Failure(it, true) + } + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt new file mode 100644 index 0000000000..0d3423e179 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +import io.element.android.libraries.architecture.Async + +data class RoomMemberDetailsState( + val userId: String, + val userName: String?, + val avatarUrl: String?, + val isBlocked: Async<Boolean>, + val displayConfirmationDialog: ConfirmationDialog? = null, + val isCurrentUser: Boolean, + val eventSink: (RoomMemberDetailsEvents) -> Unit +) { + enum class ConfirmationDialog { + Block, Unblock + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt new file mode 100644 index 0000000000..6883b20898 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async + +open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberDetailsState> { + override val values: Sequence<RoomMemberDetailsState> + get() = sequenceOf( + aRoomMemberDetailsState(), + aRoomMemberDetailsState().copy(userName = null), + aRoomMemberDetailsState().copy(isBlocked = Async.Success(true)), + aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block), + aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock), + aRoomMemberDetailsState().copy(isBlocked = Async.Loading(true)), + // Add other states here + ) +} + +fun aRoomMemberDetailsState() = RoomMemberDetailsState( + userId = "@daniel:domain.com", + userName = "Daniel", + avatarUrl = null, + isBlocked = Async.Success(false), + isCurrentUser = false, + eventSink = {}, +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt new file mode 100644 index 0000000000..72b9d4c20c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.members.details + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs +import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.MainActionButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.LargeHeightPreview +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun RoomMemberDetailsView( + state: RoomMemberDetailsState, + onShareUser: () -> Unit, + goBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(rememberScrollState()) + ) { + RoomMemberHeaderSection( + avatarUrl = state.avatarUrl, + userId = state.userId, + userName = state.userName, + ) + + RoomMemberMainActionsSection(onShareUser = onShareUser) + + Spacer(modifier = Modifier.height(26.dp)) + + // TODO implement send DM + // SendMessageSection(onSendMessage = { + // ... + // }) + + if (!state.isCurrentUser) { + BlockUserSection(state) + BlockUserDialogs(state) + } + } + } +} + +@Composable +internal fun RoomMemberHeaderSection( + avatarUrl: String?, + userId: String, + userName: String?, + modifier: Modifier = Modifier +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(70.dp)) { + Avatar( + avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.UserHeader), + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(24.dp)) + if (userName != null) { + Text(userName, style = ElementTheme.typography.fontHeadingLgBold) + Spacer(modifier = Modifier.height(6.dp)) + } + Text(userId, style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.height(40.dp)) + } +} + +@Composable +internal fun RoomMemberMainActionsSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + MainActionButton(title = stringResource(CommonStrings.action_share), icon = Icons.Outlined.Share, onClick = onShareUser) + } +} + +@Composable +internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(CommonStrings.action_send_message), + icon = Icons.Outlined.ChatBubbleOutline, + onClick = onSendMessage, + ) + } +} + +@LargeHeightPreview +@Composable +fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewLight { ContentToPreview(state) } + +@LargeHeightPreview +@Composable +fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomMemberDetailsState) { + RoomMemberDetailsView( + state = state, + onShareUser = {}, + goBack = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/res/drawable/ic_door_open.xml b/features/roomdetails/impl/src/main/res/drawable/ic_door_open.xml new file mode 100644 index 0000000000..5247201807 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/drawable/ic_door_open.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520Q457,520 468.5,508.5Q480,497 480,480Q480,463 468.5,451.5Q457,440 440,440Q423,440 411.5,451.5Q400,463 400,480Q400,497 411.5,508.5Q423,520 440,520ZM280,840L280,760L520,720Q520,720 520,720Q520,720 520,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,718Q600,747 581,769.5Q562,792 533,797L280,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800Q120,783 131.5,771.5Q143,760 160,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L800,760Q817,760 828.5,771.5Q840,783 840,800Q840,817 828.5,828.5Q817,840 800,840L160,840Z"/> +</vector> diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..6e92891c70 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 osoba"</item> + <item quantity="few">"%1$d osoby"</item> + <item quantity="other">"%1$d osob"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Přidat téma"</string> + <string name="screen_room_details_already_a_member">"Již členem"</string> + <string name="screen_room_details_already_invited">"Již pozván(a)"</string> + <string name="screen_room_details_edit_room_title">"Upravit místnost"</string> + <string name="screen_room_details_edition_error">"Došlo k neznámé chybě a informace nebylo možné změnit."</string> + <string name="screen_room_details_edition_error_title">"Nelze aktualizovat místnost"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Zprávy jsou zabezpečeny zámky. Pouze vy a příjemci máte jedinečné klíče k jejich odemčení."</string> + <string name="screen_room_details_encryption_enabled_title">"Šifrování zpráv povoleno"</string> + <string name="screen_room_details_invite_people_title">"Pozvat lidi"</string> + <string name="screen_room_details_notification_title">"Oznámení"</string> + <string name="screen_room_details_room_name_label">"Název místnosti"</string> + <string name="screen_room_details_share_room_title">"Sdílet místnost"</string> + <string name="screen_room_details_updating_room">"Aktualizace místnosti…"</string> + <string name="screen_room_member_list_pending_header_title">"Nevyřízeno"</string> + <string name="screen_room_member_list_room_members_header_title">"Členové místnosti"</string> + <string name="screen_dm_details_block_alert_action">"Zablokovat"</string> + <string name="screen_dm_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."</string> + <string name="screen_dm_details_block_user">"Zablokovat uživatele"</string> + <string name="screen_dm_details_unblock_alert_action">"Odblokovat"</string> + <string name="screen_dm_details_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string> + <string name="screen_dm_details_unblock_user">"Odblokovat uživatele"</string> + <string name="screen_room_details_leave_room_title">"Opustit místnost"</string> + <string name="screen_room_details_people_title">"Lidé"</string> + <string name="screen_room_details_security_title">"Zabezpečení"</string> + <string name="screen_room_details_topic_title">"Téma"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..992ca94b85 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 Person"</item> + <item quantity="other">"%1$d Personen"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Thema hinzufügen"</string> + <string name="screen_room_details_already_a_member">"Bereits Mitglied"</string> + <string name="screen_room_details_already_invited">"Bereits eingeladen"</string> + <string name="screen_room_details_edit_room_title">"Raum bearbeiten"</string> + <string name="screen_room_details_edition_error">"Wir konnten nicht alle Informationen für diesen Raum aktualisieren."</string> + <string name="screen_room_details_edition_error_title">"Raum konnte nicht aktualisiert werden"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Nachrichten sind mit Schlössern gesichert. Nur du und der Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren."</string> + <string name="screen_room_details_encryption_enabled_title">"Nachrichtenverschlüsselung aktiviert"</string> + <string name="screen_room_details_invite_people_title">"Personen einladen"</string> + <string name="screen_room_details_notification_title">"Benachrichtigungen"</string> + <string name="screen_room_details_room_name_label">"Raumname"</string> + <string name="screen_room_details_share_room_title">"Raum teilen"</string> + <string name="screen_room_details_updating_room">"Aktualisiere Raum…"</string> + <string name="screen_room_member_list_pending_header_title">"Ausstehend"</string> + <string name="screen_room_member_list_room_members_header_title">"Raummitglieder"</string> + <string name="screen_dm_details_block_alert_action">"Blockieren"</string> + <string name="screen_dm_details_block_alert_description">"Blockierte Benutzer können dir keine Nachrichten senden und alle Nachrichten von ihnen werden ausgeblendet. Du kannst diese Aktion jederzeit rückgängig machen."</string> + <string name="screen_dm_details_block_user">"Nutzer blockieren"</string> + <string name="screen_dm_details_unblock_alert_action">"Blockierung aufheben"</string> + <string name="screen_dm_details_unblock_alert_description">"Wenn du den Benutzer entsperrst, kannst du wieder alle Nachrichten von ihm sehen."</string> + <string name="screen_dm_details_unblock_user">"Nutzer entblockieren"</string> + <string name="screen_room_details_leave_room_title">"Raum verlassen"</string> + <string name="screen_room_details_people_title">"Personen"</string> + <string name="screen_room_details_security_title">"Sicherheit"</string> + <string name="screen_room_details_topic_title">"Thema"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..42bce4b756 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"Una persona"</item> + <item quantity="other">"%1$d personas"</item> + </plurals> + <string name="screen_room_details_encryption_enabled_subtitle">"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."</string> + <string name="screen_room_details_encryption_enabled_title">"Cifrado de mensajes activado"</string> + <string name="screen_room_details_invite_people_title">"Invitar a otras personas"</string> + <string name="screen_room_details_share_room_title">"Compartir sala"</string> + <string name="screen_dm_details_block_alert_action">"Bloquear"</string> + <string name="screen_dm_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."</string> + <string name="screen_dm_details_block_user">"Bloquear usuario"</string> + <string name="screen_dm_details_unblock_alert_action">"Desbloquear"</string> + <string name="screen_dm_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string> + <string name="screen_dm_details_unblock_user">"Desbloquear usuario"</string> + <string name="screen_room_details_leave_room_title">"Salir de la sala"</string> + <string name="screen_room_details_people_title">"Personas"</string> + <string name="screen_room_details_security_title">"Seguridad"</string> + <string name="screen_room_details_topic_title">"Tema"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..ee34445805 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 membre"</item> + <item quantity="other">"%1$d membres"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Définir un sujet"</string> + <string name="screen_room_details_already_a_member">"Déjà membre"</string> + <string name="screen_room_details_already_invited">"Déjà invité(e)"</string> + <string name="screen_room_details_edit_room_title">"Modifier le salon"</string> + <string name="screen_room_details_edition_error">"Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées."</string> + <string name="screen_room_details_edition_error_title">"Impossible de mettre à jour le salon"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."</string> + <string name="screen_room_details_encryption_enabled_title">"Chiffrement des messages activé"</string> + <string name="screen_room_details_invite_people_title">"Inviter des personnes"</string> + <string name="screen_room_details_notification_title">"Notifications"</string> + <string name="screen_room_details_room_name_label">"Nom du salon"</string> + <string name="screen_room_details_share_room_title">"Partager le salon"</string> + <string name="screen_room_details_updating_room">"Mise à jour du salon…"</string> + <string name="screen_room_member_list_pending_header_title">"En attente"</string> + <string name="screen_dm_details_block_alert_action">"Bloquer"</string> + <string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment."</string> + <string name="screen_dm_details_block_user">"Bloquer l\'utilisateur"</string> + <string name="screen_dm_details_unblock_alert_action">"Débloquer"</string> + <string name="screen_dm_details_unblock_alert_description">"Lorsque vous débloquez l\'utilisateur, vous pourrez à nouveau voir tous leur messages."</string> + <string name="screen_dm_details_unblock_user">"Débloquer l\'utilisateur"</string> + <string name="screen_room_details_leave_room_title">"Quitter le salon"</string> + <string name="screen_room_details_people_title">"Personnes"</string> + <string name="screen_room_details_security_title">"Sécurité"</string> + <string name="screen_room_details_topic_title">"Sujet"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..190eda82ee --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 persona"</item> + <item quantity="other">"%1$d persone"</item> + </plurals> + <string name="screen_room_details_encryption_enabled_subtitle">"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."</string> + <string name="screen_room_details_encryption_enabled_title">"Crittografia messaggi abilitata"</string> + <string name="screen_room_details_invite_people_title">"Invita persone"</string> + <string name="screen_room_details_share_room_title">"Condividi stanza"</string> + <string name="screen_dm_details_block_alert_action">"Blocca"</string> + <string name="screen_dm_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string> + <string name="screen_dm_details_block_user">"Blocca utente"</string> + <string name="screen_dm_details_unblock_alert_action">"Sblocca"</string> + <string name="screen_dm_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string> + <string name="screen_dm_details_unblock_user">"Sblocca utente"</string> + <string name="screen_room_details_leave_room_title">"Esci dalla stanza"</string> + <string name="screen_room_details_people_title">"Persone"</string> + <string name="screen_room_details_security_title">"Sicurezza"</string> + <string name="screen_room_details_topic_title">"Oggetto"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..c20cdab3f9 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"o persoană"</item> + <item quantity="other">"%1$d persoane"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Adăugare subiect"</string> + <string name="screen_room_details_already_a_member">"Deja membru"</string> + <string name="screen_room_details_already_invited">"Deja invitat"</string> + <string name="screen_room_details_edit_room_title">"Editați camera"</string> + <string name="screen_room_details_edition_error">"A apărut o eroare la actualizarea detaliilor camerei"</string> + <string name="screen_room_details_edition_error_title">"Nu s-a putut actualiza camera"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string> + <string name="screen_room_details_encryption_enabled_title">"Criptarea mesajelor este activată"</string> + <string name="screen_room_details_invite_people_title">"Invitați persoane"</string> + <string name="screen_room_details_notification_title">"Notificare"</string> + <string name="screen_room_details_room_name_label">"Numele camerei"</string> + <string name="screen_room_details_share_room_title">"Partajați camera"</string> + <string name="screen_room_details_updating_room">"Se actualizează camera…"</string> + <string name="screen_room_member_list_pending_header_title">"În așteptare"</string> + <string name="screen_room_member_list_room_members_header_title">"Membrii camerei"</string> + <string name="screen_dm_details_block_alert_action">"Blocați"</string> + <string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string> + <string name="screen_dm_details_block_user">"Blocați utilizatorul"</string> + <string name="screen_dm_details_unblock_alert_action">"Deblocați"</string> + <string name="screen_dm_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string> + <string name="screen_dm_details_unblock_user">"Deblocați utilizatorul"</string> + <string name="screen_room_details_leave_room_title">"Părăsiți camera"</string> + <string name="screen_room_details_people_title">"Persoane"</string> + <string name="screen_room_details_security_title">"Securitate"</string> + <string name="screen_room_details_topic_title">"Subiect"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..1d744fba30 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 osoba"</item> + <item quantity="few">"%1$d ľudia"</item> + <item quantity="other">"%1$d ľudí"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Pridať tému"</string> + <string name="screen_room_details_already_a_member">"Už ste členom"</string> + <string name="screen_room_details_already_invited">"Už ste pozvaní"</string> + <string name="screen_room_details_edit_room_title">"Upraviť miestnosť"</string> + <string name="screen_room_details_edition_error">"Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť."</string> + <string name="screen_room_details_edition_error_title">"Nepodarilo sa aktualizovať miestnosť"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."</string> + <string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string> + <string name="screen_room_details_error_loading_notification_settings">"Pri načítaní nastavení oznámení došlo k chybe."</string> + <string name="screen_room_details_error_muting">"Nepodarilo sa stlmiť túto miestnosť, skúste to prosím znova."</string> + <string name="screen_room_details_error_unmuting">"Nepodarilo sa zrušiť stlmenie tejto miestnosti, skúste to prosím znova."</string> + <string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string> + <string name="screen_room_details_notification_mode_custom">"Vlastné"</string> + <string name="screen_room_details_notification_mode_default">"Predvolené"</string> + <string name="screen_room_details_notification_title">"Oznámenia"</string> + <string name="screen_room_details_room_name_label">"Názov miestnosti"</string> + <string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string> + <string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string> + <string name="screen_room_member_list_pending_header_title">"Čaká sa"</string> + <string name="screen_room_member_list_room_members_header_title">"Členovia miestnosti"</string> + <string name="screen_dm_details_block_alert_action">"Zablokovať"</string> + <string name="screen_dm_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string> + <string name="screen_dm_details_block_user">"Zablokovať používateľa"</string> + <string name="screen_dm_details_unblock_alert_action">"Odblokovať"</string> + <string name="screen_dm_details_unblock_alert_description">"Všetky správy od nich budete môcť opäť vidieť."</string> + <string name="screen_dm_details_unblock_user">"Odblokovať používateľa"</string> + <string name="screen_room_details_leave_room_title">"Opustiť miestnosť"</string> + <string name="screen_room_details_people_title">"Ľudia"</string> + <string name="screen_room_details_security_title">"Bezpečnosť"</string> + <string name="screen_room_details_topic_title">"Téma"</string> +</resources> diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..158ba386b4 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <plurals name="screen_room_member_list_header_title"> + <item quantity="one">"1 person"</item> + <item quantity="other">"%1$d people"</item> + </plurals> + <string name="screen_room_details_add_topic_title">"Add topic"</string> + <string name="screen_room_details_already_a_member">"Already a member"</string> + <string name="screen_room_details_already_invited">"Already invited"</string> + <string name="screen_room_details_edit_room_title">"Edit Room"</string> + <string name="screen_room_details_edition_error">"There was an unknown error and the information couldn\'t be changed."</string> + <string name="screen_room_details_edition_error_title">"Unable to update room"</string> + <string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string> + <string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string> + <string name="screen_room_details_error_loading_notification_settings">"An error occurred when loading notification settings."</string> + <string name="screen_room_details_error_muting">"Failed muting this room, please try again."</string> + <string name="screen_room_details_error_unmuting">"Failed unmuting this room, please try again."</string> + <string name="screen_room_details_invite_people_title">"Invite people"</string> + <string name="screen_room_details_notification_mode_custom">"Custom"</string> + <string name="screen_room_details_notification_mode_default">"Default"</string> + <string name="screen_room_details_notification_title">"Notifications"</string> + <string name="screen_room_details_room_name_label">"Room name"</string> + <string name="screen_room_details_share_room_title">"Share room"</string> + <string name="screen_room_details_updating_room">"Updating room…"</string> + <string name="screen_room_member_list_pending_header_title">"Pending"</string> + <string name="screen_room_member_list_room_members_header_title">"Room members"</string> + <string name="screen_dm_details_block_alert_action">"Block"</string> + <string name="screen_dm_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string> + <string name="screen_dm_details_block_user">"Block user"</string> + <string name="screen_dm_details_unblock_alert_action">"Unblock"</string> + <string name="screen_dm_details_unblock_alert_description">"You\'ll be able to see all messages from them again."</string> + <string name="screen_dm_details_unblock_user">"Unblock user"</string> + <string name="screen_room_details_leave_room_title">"Leave room"</string> + <string name="screen_room_details_people_title">"People"</string> + <string name="screen_room_details_security_title">"Security"</string> + <string name="screen_room_details_topic_title">"Topic"</string> +</resources> diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt new file mode 100644 index 0000000000..ccd1476c3a --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake +import io.element.android.features.roomdetails.impl.RoomDetailsEvent +import io.element.android.features.roomdetails.impl.RoomDetailsPresenter +import io.element.android.features.roomdetails.impl.RoomDetailsType +import io.element.android.features.roomdetails.impl.RoomTopicState +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomDetailsPresenterTests { + + private fun aRoomDetailsPresenter(room: MatrixRoom, leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake()): RoomDetailsPresenter { + val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMemberId) + } + } + return RoomDetailsPresenter(room, roomMemberDetailsPresenterFactory, leaveRoomPresenter) + } + + @Test + fun `present - initial state is created from room info`() = runTest { + val room = aMatrixRoom() + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomId).isEqualTo(room.roomId.value) + assertThat(initialState.roomName).isEqualTo(room.displayName) + assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) + assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!)) + assertThat(initialState.memberCount).isEqualTo(room.joinedMemberCount) + assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state with no room name`() = runTest { + val room = aMatrixRoom(name = null) + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomName).isEqualTo(room.displayName) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state with DM member sets custom DM roomType`() = runTest { + val myRoomMember = aRoomMember(A_SESSION_ID) + val otherRoomMember = aRoomMember(A_USER_ID_2) + val room = aMatrixRoom( + isEncrypted = true, + isDirect = true, + ).apply { + val roomMembers = listOf(myRoomMember, otherRoomMember) + givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember)) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can invite others to room`() = runTest { + val room = aMatrixRoom().apply { + givenCanInviteResult(Result.success(true)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + assertThat(awaitItem().canInvite).isFalse() + // Then the asynchronous check completes and it becomes true + assertThat(awaitItem().canInvite).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can not invite others to room`() = runTest { + val room = aMatrixRoom().apply { + givenCanInviteResult(Result.success(false)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().canInvite).isFalse() + } + } + + @Test + fun `present - initial state when canInvite errors`() = runTest { + val room = aMatrixRoom().apply { + givenCanInviteResult(Result.failure(Throwable("Whoops"))) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().canInvite).isFalse() + } + } + + @Test + fun `present - initial state when user can edit one attribute`() = runTest { + val room = aMatrixRoom().apply { + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Whelp"))) + givenCanInviteResult(Result.success(false)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + assertThat(awaitItem().canEdit).isFalse() + // Then the asynchronous check completes and it becomes true + assertThat(awaitItem().canEdit).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can edit attributes in a DM`() = runTest { + val myRoomMember = aRoomMember(A_SESSION_ID) + val otherRoomMember = aRoomMember(A_USER_ID_2) + val room = aMatrixRoom( + isEncrypted = true, + isDirect = true, + ).apply { + val roomMembers = listOf(myRoomMember, otherRoomMember) + givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) + + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true)) + givenCanInviteResult(Result.success(false)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + assertThat(awaitItem().canEdit).isFalse() + // Then the asynchronous check completes, but editing is still disallowed because it's a DM + val settledState = awaitItem() + assertThat(settledState.canEdit).isFalse() + // If there is a topic, it's visible + assertThat(settledState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!)) + + cancelAndIgnoreRemainingEvents() + } + } + @Test + fun `present - initial state when in a DM with no topic`() = runTest { + val myRoomMember = aRoomMember(A_SESSION_ID) + val otherRoomMember = aRoomMember(A_USER_ID_2) + val room = aMatrixRoom( + isEncrypted = true, + isDirect = true, + topic = null, + ).apply { + val roomMembers = listOf(myRoomMember, otherRoomMember) + givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) + + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + // There's no topic, so we hide the entire UI for DMs + assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can edit all attributes`() = runTest { + val room = aMatrixRoom().apply { + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true)) + givenCanInviteResult(Result.success(false)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + assertThat(awaitItem().canEdit).isFalse() + // Then the asynchronous check completes and it becomes true + assertThat(awaitItem().canEdit).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can edit no attributes`() = runTest { + val room = aMatrixRoom().apply { + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false)) + givenCanInviteResult(Result.success(false)) + } + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false, and no further events + assertThat(awaitItem().canEdit).isFalse() + } + } + + @Test + fun `present - topic state is hidden when no topic and user has no permission`() = runTest { + val room = aMatrixRoom(topic = null).apply { + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(false)) + givenCanInviteResult(Result.success(false)) + } + + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // The initial state is "hidden" and no further state changes happen + assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden) + } + } + + @Test + fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest { + val room = aMatrixRoom(topic = null).apply { + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + givenCanInviteResult(Result.success(false)) + } + + val presenter = aRoomDetailsPresenter(room) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Ignore the initial state + skipItems(1) + + // When the async permission check finishes, the topic state will be updated + assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.CanAddTopic) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - leave room event is passed on to leave room presenter`() = runTest { + val leaveRoomPresenter = LeaveRoomPresenterFake() + val room = aMatrixRoom() + val presenter = aRoomDetailsPresenter(room, leaveRoomPresenter) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(RoomDetailsEvent.LeaveRoom) + + assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId)) + + cancelAndIgnoreRemainingEvents() + } + } +} + +fun aMatrixRoom( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + displayName: String = "A fallback display name", + topic: String? = "A topic", + avatarUrl: String? = "https://matrix.org/avatar.jpg", + isEncrypted: Boolean = true, + isPublic: Boolean = true, + isDirect: Boolean = false, +) = FakeMatrixRoom( + roomId = roomId, + name = name, + displayName = displayName, + topic = topic, + avatarUrl = avatarUrl, + isEncrypted = isEncrypted, + isPublic = isPublic, + isDirect = isDirect, +) + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt new file mode 100644 index 0000000000..20d253f3fb --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -0,0 +1,617 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.edit + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdetails.aMatrixRoom +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File + +@ExperimentalCoroutinesApi +class RoomDetailsEditPresenterTest { + + private lateinit var fakePickerProvider: FakePickerProvider + private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor + + private val roomAvatarUri: Uri = mockk() + private val anotherAvatarUri: Uri = mockk() + + private val fakeFileContents = ByteArray(2) + + @Before + fun setup() { + fakePickerProvider = FakePickerProvider() + fakeMediaPreProcessor = FakeMediaPreProcessor() + mockkStatic(Uri::class) + + every { Uri.parse(AN_AVATAR_URL) } returns roomAvatarUri + every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun aRoomDetailsEditPresenter(room: MatrixRoom): RoomDetailsEditPresenter { + return RoomDetailsEditPresenter( + room = room, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, + ) + } + + @Test + fun `present - initial state is created from room info`() = runTest { + val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL) + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomId).isEqualTo(room.roomId.value) + assertThat(initialState.roomName).isEqualTo(room.name) + assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) + assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty()) + assertThat(initialState.avatarActions).containsExactly( + AvatarAction.ChoosePhoto, + AvatarAction.TakePhoto, + AvatarAction.Remove + ) + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + assertThat(initialState.saveAction).isInstanceOf(Async.Uninitialized::class.java) + } + } + + @Test + fun `present - sets canChangeName if user has permission`() = runTest { + val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply { + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) + } + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + val initialState = awaitItem() + assertThat(initialState.canChangeName).isFalse() + assertThat(initialState.canChangeAvatar).isFalse() + assertThat(initialState.canChangeTopic).isFalse() + + // When the asynchronous check completes, the single field we can edit is true + val settledState = awaitItem() + assertThat(settledState.canChangeName).isTrue() + assertThat(settledState.canChangeAvatar).isFalse() + assertThat(settledState.canChangeTopic).isFalse() + } + } + + @Test + fun `present - sets canChangeAvatar if user has permission`() = runTest { + val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply { + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.success(true)) + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) + } + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + val initialState = awaitItem() + assertThat(initialState.canChangeName).isFalse() + assertThat(initialState.canChangeAvatar).isFalse() + assertThat(initialState.canChangeTopic).isFalse() + + // When the asynchronous check completes, the single field we can edit is true + val settledState = awaitItem() + assertThat(settledState.canChangeName).isFalse() + assertThat(settledState.canChangeAvatar).isTrue() + assertThat(settledState.canChangeTopic).isFalse() + } + } + + @Test + fun `present - sets canChangeTopic if user has permission`() = runTest { + val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL).apply { + givenCanSendStateResult(StateEventType.ROOM_NAME, Result.success(false)) + givenCanSendStateResult(StateEventType.ROOM_AVATAR, Result.failure(Throwable("Oops"))) + givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) + } + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + // Initially false + val initialState = awaitItem() + assertThat(initialState.canChangeName).isFalse() + assertThat(initialState.canChangeAvatar).isFalse() + assertThat(initialState.canChangeTopic).isFalse() + + // When the asynchronous check completes, the single field we can edit is true + val settledState = awaitItem() + assertThat(settledState.canChangeName).isFalse() + assertThat(settledState.canChangeAvatar).isFalse() + assertThat(settledState.canChangeTopic).isTrue() + } + } + + @Test + fun `present - updates state in response to changes`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomTopic).isEqualTo("My topic") + assertThat(initialState.roomName).isEqualTo("Name") + assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("My topic") + assertThat(roomName).isEqualTo("Name II") + assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) + } + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("My topic") + assertThat(roomName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) + } + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("Another topic") + assertThat(roomName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) + } + + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("Another topic") + assertThat(roomName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isNull() + } + } + } + + @Test + fun `present - obtains avatar uris from gallery`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(anotherAvatarUri) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) + + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) + } + } + } + + @Test + fun `present - obtains avatar uris from camera`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(anotherAvatarUri) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) + + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + awaitItem().apply { + assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) + } + } + } + + @Test + fun `present - updates save button state`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(roomAvatarUri) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // If it's reverted then the save disables again + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + } + } + + @Test + fun `present - updates save button state when initial values are null`() = runTest { + val room = aMatrixRoom(topic = null, name = null, displayName = "fallback", avatarUrl = null) + + fakePickerProvider.givenResult(roomAvatarUri) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isEqualTo(false) + + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // If it's reverted then the save disables again + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(true) + } + + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isEqualTo(false) + } + } + } + + @Test + fun `present - save changes room details if different`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(5) + assertThat(room.newName).isEqualTo("New name") + assertThat(room.newTopic).isEqualTo("New topic") + assertThat(room.newAvatarData).isNull() + assertThat(room.removedAvatar).isTrue() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change room details if they're the same trimmed`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) + initialState.eventSink(RoomDetailsEditEvents.Save) + + assertThat(room.newName).isNull() + assertThat(room.newTopic).isNull() + assertThat(room.newAvatarData).isNull() + assertThat(room.removedAvatar).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change topic if it was unset and is now blank`() = runTest { + val room = aMatrixRoom(topic = null, name = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) + initialState.eventSink(RoomDetailsEditEvents.Save) + + assertThat(room.newName).isNull() + assertThat(room.newTopic).isNull() + assertThat(room.newAvatarData).isNull() + assertThat(room.removedAvatar).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change name if it's now empty`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) + initialState.eventSink(RoomDetailsEditEvents.Save) + + assertThat(room.newName).isNull() + assertThat(room.newTopic).isNull() + assertThat(room.newAvatarData).isNull() + assertThat(room.removedAvatar).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save processes and sets avatar when processor returns successfully`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + givenPickerReturnsFile() + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(3) + + assertThat(room.newName).isNull() + assertThat(room.newTopic).isNull() + assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents) + assertThat(room.removedAvatar).isFalse() + } + } + + @Test + fun `present - save does not set avatar data if processor fails`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) + + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(2) + + assertThat(room.newName).isNull() + assertThat(room.newTopic).isNull() + assertThat(room.newAvatarData).isNull() + assertThat(room.removedAvatar).isFalse() + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + } + } + + @Test + fun `present - sets save action to failure if name update fails`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply { + givenSetNameResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name")) + } + + @Test + fun `present - sets save action to failure if topic update fails`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply { + givenSetTopicResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic")) + } + + @Test + fun `present - sets save action to failure if removing avatar fails`() = runTest { + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply { + givenRemoveAvatarResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + } + + @Test + fun `present - sets save action to failure if setting avatar fails`() = runTest { + givenPickerReturnsFile() + + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply { + givenUpdateAvatarResult(Result.failure(Throwable("!"))) + } + + saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + } + + @Test + fun `present - CancelSaveChanges resets save action state`() = runTest { + givenPickerReturnsFile() + + val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL).apply { + givenSetTopicResult(Result.failure(Throwable("!"))) + } + + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(2) + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + + initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges) + assertThat(awaitItem().saveAction).isInstanceOf(Async.Uninitialized::class.java) + } + } + + private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) { + val presenter = aRoomDetailsEditPresenter(room) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(event) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(1) + + assertThat(awaitItem().saveAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().saveAction).isInstanceOf(Async.Failure::class.java) + } + } + + private fun givenPickerReturnsFile() { + mockkStatic(File::readBytes) + val processedFile: File = mockk { + every { readBytes() } returns fakeFileContents + } + + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult( + Result.success( + MediaUploadInfo.AnyFile( + file = processedFile, + fileInfo = mockk(), + ) + ) + ) + } + + companion object { + private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg" + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt new file mode 100644 index 0000000000..8600cefeac --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersPresenterTest.kt @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.impl.invite + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdetails.aMatrixRoom +import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.roomdetails.impl.members.aRoomMemberList +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.usersearch.api.UserSearchResult +import io.element.android.libraries.usersearch.test.FakeUserRepository +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class RoomInviteMembersPresenterTest { + + @Test + fun `present - initial state has no results and no search`() = runTest { + val presenter = RoomInviteMembersPresenter( + userRepository = FakeUserRepository(), + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers() + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + assertThat(initialState.isSearchActive).isFalse() + assertThat(initialState.canInvite).isFalse() + assertThat(initialState.searchQuery).isEmpty() + + skipItems(1) + } + } + + @Test + fun `present - updates search active state`() = runTest { + val presenter = RoomInviteMembersPresenter( + userRepository = FakeUserRepository(), + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers() + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(true)) + + val resultState = awaitItem() + assertThat(resultState.isSearchActive).isTrue() + } + } + + @Test + fun `present - performs search and handles no results`() = runTest { + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitResult(emptyList()) + skipItems(1) + + val resultState = awaitItem() + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `present - performs search and handles user results`() = runTest { + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitResult(aMatrixUserList().map { UserSearchResult(it) }) + skipItems(1) + + val resultState = awaitItem() + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + + val expectedUsers = aMatrixUserList() + val users = resultState.searchResults.users() + expectedUsers.forEachIndexed { index, matrixUser -> + assertThat(users[index].matrixUser).isEqualTo(matrixUser) + assertThat(users[index].isAlreadyInvited).isFalse() + assertThat(users[index].isAlreadyJoined).isFalse() + assertThat(users[index].isSelected).isFalse() + } + } + } + + @Test + fun `present - performs search and handles membership state of existing users`() = runTest { + val userList = aMatrixUserList() + val joinedUser = userList[0] + val invitedUser = userList[1] + + val repository = FakeUserRepository() + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource( + matrixRoom = FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), + aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), + ) + ) + ) + }, + coroutineDispatchers = coroutineDispatchers, + ), + coroutineDispatchers = coroutineDispatchers + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitResult(aMatrixUserList().map { UserSearchResult(it) }) + skipItems(1) + + val resultState = awaitItem() + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + + val users = resultState.searchResults.users() + + // The result that matches a user with JOINED membership is marked as such + val userWhoShouldBeJoined = users.find { it.matrixUser == joinedUser } + assertThat(userWhoShouldBeJoined).isNotNull() + assertThat(userWhoShouldBeJoined?.isAlreadyJoined).isTrue() + assertThat(userWhoShouldBeJoined?.isAlreadyInvited).isFalse() + + // The result that matches a user with INVITED membership is marked as such + val userWhoShouldBeInvited = users.find { it.matrixUser == invitedUser } + assertThat(userWhoShouldBeInvited).isNotNull() + assertThat(userWhoShouldBeInvited?.isAlreadyJoined).isFalse() + assertThat(userWhoShouldBeInvited?.isAlreadyInvited).isTrue() + + // All other users are neither joined nor invited + val otherUsers = users.minus(userWhoShouldBeInvited!!).minus(userWhoShouldBeJoined!!) + assertThat(otherUsers.none { it.isAlreadyInvited }).isTrue() + assertThat(otherUsers.none { it.isAlreadyJoined }).isTrue() + } + } + + @Test + fun `present - performs search and handles unresolved results`() = runTest { + val userList = aMatrixUserList() + val joinedUser = userList[0] + val invitedUser = userList[1] + + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply { + givenRoomMembersState( + MatrixRoomMembersState.Ready( + listOf( + aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN), + aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE), + ) + ) + ) + }), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + + val unresolvedUser = UserSearchResult(aMatrixUser(id = A_USER_ID.value), isUnresolved = true) + repository.emitResult(listOf(unresolvedUser) + aMatrixUserList().map { UserSearchResult(it) }) + skipItems(1) + + val resultState = awaitItem() + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + + val users = resultState.searchResults.users() + + val userWhoShouldBeUnresolved = users.first() + assertThat(userWhoShouldBeUnresolved.isUnresolved).isTrue() + + // All other users are neither joined nor invited + val otherUsers = users.minus(userWhoShouldBeUnresolved) + assertThat(otherUsers.none { it.isUnresolved }).isTrue() + } + } + + @Test + fun `present - toggle users updates selected user state`() = runTest { + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(), + coroutineDispatchers = testCoroutineDispatchers() + ) + + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + // When we toggle a user not in the list, they are added + initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser())) + assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser()) + + // Toggling a different user also adds them + initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value))) + assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(), aMatrixUser(id = A_USER_ID_2.value)) + + // Toggling the first user removes them + initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser())) + assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value)) + } + } + + @Test + fun `present - selected users appear as such in search results`() = runTest { + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + val selectedUser = aMatrixUser() + + initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser)) + + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) }) + skipItems(2) + + val resultState = awaitItem() + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + + val users = resultState.searchResults.users() + + // The one user we have previously toggled is marked as selected + val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser } + assertThat(shouldBeSelectedUser).isNotNull() + assertThat(shouldBeSelectedUser?.isSelected).isTrue() + + // And no others are + val allOtherUsers = users.minus(shouldBeSelectedUser!!) + assertThat(allOtherUsers.none { it.isSelected }).isTrue() + } + } + + @Test + fun `present - toggling a user updates existing search results`() = runTest { + val repository = FakeUserRepository() + val presenter = RoomInviteMembersPresenter( + userRepository = repository, + roomMemberListDataSource = createDataSource(FakeMatrixRoom()), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + skipItems(1) + + val selectedUser = aMatrixUser() + + // Given a query is made + initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) }) + skipItems(2) + + // And then a user is toggled + initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser)) + skipItems(1) + val resultState = awaitItem() + + // The results are updated... + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + val users = resultState.searchResults.users() + + // The one user we have now toggled is marked as selected + val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser } + assertThat(shouldBeSelectedUser).isNotNull() + assertThat(shouldBeSelectedUser?.isSelected).isTrue() + + // And no others are + val allOtherUsers = users.minus(shouldBeSelectedUser!!) + assertThat(allOtherUsers.none { it.isSelected }).isTrue() + } + } + + private fun TestScope.createDataSource( + matrixRoom: MatrixRoom = aMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList())) + }, + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() + ) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers) + + private fun SearchBarResultState<ImmutableList<InvitableUser>>.users() = + (this as? SearchBarResultState.Results<ImmutableList<InvitableUser>>)?.results.orEmpty() +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt new file mode 100644 index 0000000000..c3a79481e6 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.members + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource +import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents +import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter +import io.element.android.features.roomdetails.impl.members.aRoomMemberList +import io.element.android.features.roomdetails.impl.members.aVictor +import io.element.android.features.roomdetails.impl.members.aWalter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberListPresenterTests { + + @Test + fun `search is done automatically on start, but is async`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java) + Truth.assertThat(initialState.searchQuery).isEmpty() + Truth.assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) + Truth.assertThat(initialState.isSearchActive).isFalse() + + val loadedState = awaitItem() + Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java) + Truth.assertThat((loadedState.roomMembers as Async.Success).data.invited).isEqualTo(listOf(aVictor(), aWalter())) + Truth.assertThat((loadedState.roomMembers as Async.Success).data.joined).isNotEmpty() + } + } + + @Test + fun `open search`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val loadedState = awaitItem() + + loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) + + val searchActiveState = awaitItem() + Truth.assertThat((searchActiveState.isSearchActive)).isTrue() + } + } + + @Test + fun `search for something which is not found`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val loadedState = awaitItem() + loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) + val searchActiveState = awaitItem() + loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something")) + val searchQueryUpdatedState = awaitItem() + Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something") + val searchSearchResultDelivered = awaitItem() + Truth.assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java) + } + } + + @Test + fun `search for something which is found`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val loadedState = awaitItem() + loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true)) + val searchActiveState = awaitItem() + loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice")) + val searchQueryUpdatedState = awaitItem() + Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice") + val searchSearchResultDelivered = awaitItem() + Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(SearchBarResultState.Results::class.java) + Truth.assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName) + .isEqualTo("Alice") + + } + } + + @Test + fun `present - asynchronously sets canInvite when user has correct power level`() = runTest { + val presenter = createPresenter( + matrixRoom = FakeMatrixRoom().apply { + givenCanInviteResult(Result.success(true)) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + Truth.assertThat(loadedState.canInvite).isTrue() + } + } + + @Test + fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest { + val presenter = createPresenter( + matrixRoom = FakeMatrixRoom().apply { + givenCanInviteResult(Result.success(false)) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + Truth.assertThat(loadedState.canInvite).isFalse() + } + } + + @Test + fun `present - asynchronously sets canInvite when power level check fails`() = runTest { + val presenter = createPresenter( + matrixRoom = FakeMatrixRoom().apply { + givenCanInviteResult(Result.failure(Throwable("Eek"))) + } + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + Truth.assertThat(loadedState.canInvite).isFalse() + } + } +} + +@ExperimentalCoroutinesApi +private fun TestScope.createDataSource( + matrixRoom: MatrixRoom = FakeMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList())) + }, + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() +) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers) + +@ExperimentalCoroutinesApi +private fun TestScope.createPresenter( + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixRoom: MatrixRoom = FakeMatrixRoom(), + roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers), +) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt new file mode 100644 index 0000000000..94b940bb17 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomdetails.members.details + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.roomdetails.aMatrixRoom +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberDetailsPresenterTests { + + @Test + fun `present - returns the room member's data, then updates it if needed`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") + val room = aMatrixRoom().apply { + givenUserDisplayNameResult(Result.success("A custom name")) + givenUserAvatarUrlResult(Result.success("A custom avatar")) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) + } + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId.value) + Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) + Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) + Truth.assertThat(initialState.isBlocked).isEqualTo(Async.Success(roomMember.isIgnored)) + skipItems(1) + val loadedState = awaitItem() + Truth.assertThat(loadedState.userName).isEqualTo("A custom name") + Truth.assertThat(loadedState.avatarUrl).isEqualTo("A custom avatar") + } + } + + @Test + fun `present - will recover when retrieving room member details fails`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") + val room = aMatrixRoom().apply { + givenUserDisplayNameResult(Result.failure(Throwable())) + givenUserAvatarUrlResult(Result.failure(Throwable())) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) + } + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) + Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - will fallback to original data if the updated data is null`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") + val room = aMatrixRoom().apply { + givenUserDisplayNameResult(Result.success(null)) + givenUserAvatarUrlResult(Result.success(null)) + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) + } + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) + Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember() + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) + + val dialogState = awaitItem() + Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Block) + + dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) + Truth.assertThat(awaitItem().displayConfirmationDialog).isNull() + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember() + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) + Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue() + Truth.assertThat(awaitItem().isBlocked.dataOrNull()).isTrue() + + initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)) + Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue() + Truth.assertThat(awaitItem().isBlocked.dataOrNull()).isFalse() + } + } + + @Test + fun `present - BlockUser with error`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember() + val matrixClient = FakeMatrixClient() + matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE)) + val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) + Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue() + val errorState = awaitItem() + Truth.assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE) + // Clear error + initialState.eventSink(RoomMemberDetailsEvents.ClearBlockUserError) + Truth.assertThat(awaitItem().isBlocked).isEqualTo(Async.Success(false)) + } + } + + @Test + fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember() + val presenter = RoomMemberDetailsPresenter(FakeMatrixClient(), room, roomMember.userId) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) + + val dialogState = awaitItem() + Truth.assertThat(dialogState.displayConfirmationDialog).isEqualTo(RoomMemberDetailsState.ConfirmationDialog.Unblock) + + dialogState.eventSink(RoomMemberDetailsEvents.ClearConfirmationDialog) + Truth.assertThat(awaitItem().displayConfirmationDialog).isNull() + + ensureAllEventsConsumed() + } + } +} diff --git a/features/roomlist/api/build.gradle.kts b/features/roomlist/api/build.gradle.kts new file mode 100644 index 0000000000..cb5efc48eb --- /dev/null +++ b/features/roomlist/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.roomlist.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt new file mode 100644 index 0000000000..7fe01aa64e --- /dev/null +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId + +interface RoomListEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onRoomClicked(roomId: RoomId) + fun onCreateRoomClicked() + fun onSettingsClicked() + fun onSessionVerificationClicked() + fun onInvitesClicked() + fun onRoomSettingsClicked(roomId: RoomId) + fun onReportBugClicked() + } +} + diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts new file mode 100644 index 0000000000..302e54fe09 --- /dev/null +++ b/features/roomlist/impl/build.gradle.kts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomlist.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.deeplink) + implementation(projects.features.invitelist.api) + implementation(projects.features.networkmonitor.api) + implementation(projects.features.leaveroom.api) + implementation(projects.services.analytics.api) + implementation(libs.accompanist.placeholder) + api(projects.features.roomlist.api) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.eventformatter.test) + testImplementation(projects.libraries.permissions.noop) + testImplementation(projects.features.invitelist.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.tests.testutils) + testImplementation(projects.features.leaveroom.fake) + + androidTestImplementation(libs.test.junitext) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomListEntryPoint.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomListEntryPoint.kt new file mode 100644 index 0000000000..25016e7a2f --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultRoomListEntryPoint.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.roomlist.api.RoomListEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultRoomListEntryPoint @Inject constructor() : RoomListEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomListEntryPoint.NodeBuilder { + + val plugins = ArrayList<Plugin>() + + return object : RoomListEntryPoint.NodeBuilder { + + override fun callback(callback: RoomListEntryPoint.Callback): RoomListEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode<RoomListNode>(buildContext, plugins) + } + } + } +} + diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt new file mode 100644 index 0000000000..cb437da20b --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesEntryPointView.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun InvitesEntryPointView( + onInvitesClicked: () -> Unit, + state: InvitesState, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClick = onInvitesClicked) + .padding(start = 24.dp, end = 16.dp) + .align(Alignment.CenterEnd) + .heightIn(min = 40.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(CommonStrings.action_invites_list), + style = ElementTheme.typography.fontBodyMdMedium, + ) + + if (state == InvitesState.NewInvites) { + Spacer(Modifier.width(8.dp)) + UnreadIndicatorAtom() + } + } + } +} + +@Preview +@Composable +internal fun InvitesEntryPointViewLightPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun InvitesEntryPointViewDarkPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: InvitesState) { + InvitesEntryPointView( + onInvitesClicked = {}, + state = state, + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesStateProvider.kt new file mode 100644 index 0000000000..f4b40c0952 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InvitesStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class InvitesStateProvider : PreviewParameterProvider<InvitesState> { + override val values: Sequence<InvitesState> + get() = sequenceOf( + InvitesState.SeenInvites, + InvitesState.NewInvites, + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt new file mode 100644 index 0000000000..740c14aa8d --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomListContextMenu( + contextMenu: RoomListState.ContextMenu.Shown, + eventSink: (RoomListEvents) -> Unit, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, + ) { + RoomListModalBottomSheetContent( + contextMenu = contextMenu, + onRoomSettingsClicked = { + eventSink(RoomListEvents.HideContextMenu) + onRoomSettingsClicked(it) + }, + onLeaveRoomClicked = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) + } + ) + } +} + +@Composable +private fun RoomListModalBottomSheetContent( + contextMenu: RoomListState.ContextMenu.Shown, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, + onLeaveRoomClicked: (roomId: RoomId) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + ListItem( + headlineContent = { + Text( + text = contextMenu.roomName, + style = ElementTheme.typography.fontBodyLgMedium, + ) + } + ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = CommonStrings.common_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + modifier = Modifier.clickable { onRoomSettingsClicked(contextMenu.roomId) }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(id = CommonStrings.common_settings), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = CommonStrings.action_leave_room), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + }, + modifier = Modifier.clickable { onLeaveRoomClicked(contextMenu.roomId) }, + leadingContent = { + Icon( + resourceId = VectorIcons.DoorOpen, + contentDescription = stringResource(id = CommonStrings.action_leave_room), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + ) + Spacer(modifier = Modifier.height(32.dp)) + } +} + +// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up. +// see: https://issuetracker.google.com/issues/283843380 +// Remove this preview when the issue is fixed. +@Preview +@Composable +internal fun RoomListModalBottomSheetContentLightPreview() = + ElementPreviewLight { ContentToPreview() } + +// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up. +// see: https://issuetracker.google.com/issues/283843380 +// Remove this preview when the issue is fixed. +@Preview +@Composable +internal fun RoomListModalBottomSheetContentDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + RoomListModalBottomSheetContent( + contextMenu = RoomListState.ContextMenu.Shown( + roomId = RoomId(value = "!aRoom:aDomain"), + roomName = "aRoom" + ), + onRoomSettingsClicked = {}, + onLeaveRoomClicked = {} + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt new file mode 100644 index 0000000000..e95b5bd60d --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface RoomListEvents { + data class UpdateFilter(val newFilter: String) : RoomListEvents + data class UpdateVisibleRange(val range: IntRange) : RoomListEvents + object DismissRequestVerificationPrompt : RoomListEvents + object ToggleSearchResults : RoomListEvents + data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents + object HideContextMenu : RoomListEvents + data class LeaveRoom(val roomId: RoomId) : RoomListEvents +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt new file mode 100644 index 0000000000..6e1bbd64d3 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.roomlist.api.RoomListEntryPoint +import io.element.android.features.roomlist.impl.components.RoomListMenuAction +import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(SessionScope::class) +class RoomListNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: RoomListPresenter, + private val inviteFriendsUseCase: InviteFriendsUseCase, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home)) + } + ) + } + + private fun onRoomClicked(roomId: RoomId) { + plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomClicked(roomId) } + } + + private fun onOpenSettings() { + plugins<RoomListEntryPoint.Callback>().forEach { it.onSettingsClicked() } + } + + private fun onCreateRoomClicked() { + plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() } + } + + private fun onSessionVerificationClicked() { + plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() } + } + + private fun onInvitesClicked() { + plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() } + } + + private fun onRoomSettingsClicked(roomId: RoomId) { + plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) } + } + + private fun onMenuActionClicked(activity: Activity, roomListMenuAction: RoomListMenuAction) { + when (roomListMenuAction) { + RoomListMenuAction.InviteFriends -> { + inviteFriendsUseCase.execute(activity) + } + RoomListMenuAction.ReportBug -> { + plugins<RoomListEntryPoint.Callback>().forEach { it.onReportBugClicked() } + } + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = LocalContext.current as Activity + RoomListView( + state = state, + onRoomClicked = this::onRoomClicked, + onSettingsClicked = this::onOpenSettings, + onCreateRoomClicked = this::onCreateRoomClicked, + onVerifyClicked = this::onSessionVerificationClicked, + onInvitesClicked = this::onInvitesClicked, + onRoomSettingsClicked = this::onRoomSettingsClicked, + onMenuActionClicked = { onMenuActionClicked(activity, it) }, + modifier = modifier, + ) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt new file mode 100644 index 0000000000..21f946b3fd --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource +import io.element.android.features.roomlist.impl.datasource.RoomListDataSource +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.getCurrentUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val extendedRangeSize = 40 + +class RoomListPresenter @Inject constructor( + private val client: MatrixClient, + private val sessionVerificationService: SessionVerificationService, + private val networkMonitor: NetworkMonitor, + private val snackbarDispatcher: SnackbarDispatcher, + private val inviteStateDataSource: InviteStateDataSource, + private val leaveRoomPresenter: LeaveRoomPresenter, + private val roomListDataSource: RoomListDataSource, +) : Presenter<RoomListState> { + + @Composable + override fun present(): RoomListState { + val leaveRoomState = leaveRoomPresenter.present() + val matrixUser: MutableState<MatrixUser?> = rememberSaveable { + mutableStateOf(null) + } + val roomList by roomListDataSource.allRooms.collectAsState() + val filteredRoomList by roomListDataSource.filteredRooms.collectAsState() + val filter by roomListDataSource.filter.collectAsState() + val networkConnectionStatus by networkMonitor.connectivity.collectAsState() + + LaunchedEffect(Unit) { + roomListDataSource.launchIn(this) + initialLoad(matrixUser) + } + + // Session verification status (unknown, not verified, verified) + val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() + var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) } + // We combine both values to only display the prompt if the session is not verified and it wasn't dismissed + val displayVerificationPrompt by remember { + derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed } + } + + var displaySearchResults by rememberSaveable { mutableStateOf(false) } + + var contextMenu by remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) } + + fun handleEvents(event: RoomListEvents) { + when (event) { + is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter) + is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) + RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true + RoomListEvents.ToggleSearchResults -> { + if (displaySearchResults) { + roomListDataSource.updateFilter("") + } + displaySearchResults = !displaySearchResults + } + is RoomListEvents.ShowContextMenu -> { + contextMenu = RoomListState.ContextMenu.Shown( + roomId = event.roomListRoomSummary.roomId, + roomName = event.roomListRoomSummary.name + ) + } + is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden + is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) + } + } + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + return RoomListState( + matrixUser = matrixUser.value, + roomList = roomList, + filter = filter, + filteredRoomList = filteredRoomList, + displayVerificationPrompt = displayVerificationPrompt, + snackbarMessage = snackbarMessage, + hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, + invitesState = inviteStateDataSource.inviteState(), + displaySearchResults = displaySearchResults, + contextMenu = contextMenu, + leaveRoomState = leaveRoomState, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch { + matrixUser.value = client.getCurrentUser() + } + + private fun updateVisibleRange(range: IntRange) { + if (range.isEmpty()) return + val midExtendedRangeSize = extendedRangeSize / 2 + val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0) + // Safe to give bigger size than room list + val extendedRangeEnd = range.last + midExtendedRangeSize + val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) + client.roomSummaryDataSource.updateAllRoomsVisibleRange(extendedRange) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt new file mode 100644 index 0000000000..7905b5bc61 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class RoomListState( + val matrixUser: MatrixUser?, + val roomList: ImmutableList<RoomListRoomSummary>, + val filter: String?, + val filteredRoomList: ImmutableList<RoomListRoomSummary>, + val displayVerificationPrompt: Boolean, + val hasNetworkConnection: Boolean, + val snackbarMessage: SnackbarMessage?, + val invitesState: InvitesState, + val displaySearchResults: Boolean, + val contextMenu: ContextMenu, + val leaveRoomState: LeaveRoomState, + val eventSink: (RoomListEvents) -> Unit, +) { + sealed interface ContextMenu { + object Hidden : ContextMenu + data class Shown( + val roomId: RoomId, + val roomName: String, + ) : ContextMenu + } +} + +enum class InvitesState { + NoInvites, + SeenInvites, + NewInvites, +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt new file mode 100644 index 0000000000..421e243504 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class RoomListStateProvider : PreviewParameterProvider<RoomListState> { + override val values: Sequence<RoomListState> + get() = sequenceOf( + aRoomListState(), + aRoomListState().copy(displayVerificationPrompt = true), + aRoomListState().copy(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), + aRoomListState().copy(hasNetworkConnection = false), + aRoomListState().copy(invitesState = InvitesState.SeenInvites), + aRoomListState().copy(invitesState = InvitesState.NewInvites), + aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), + aRoomListState().copy(displaySearchResults = true), + aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown( + roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name" + )) + ) +} + +internal fun aRoomListState() = RoomListState( + matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + roomList = aRoomListRoomSummaryList(), + filter = "filter", + filteredRoomList = aRoomListRoomSummaryList(), + hasNetworkConnection = true, + snackbarMessage = null, + displayVerificationPrompt = false, + invitesState = InvitesState.NoInvites, + displaySearchResults = false, + contextMenu = RoomListState.ContextMenu.Hidden, + leaveRoomState = LeaveRoomState(), + eventSink = {} +) + +internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> { + return persistentListOf( + RoomListRoomSummary( + name = "Room", + hasUnread = true, + timestamp = "14:18", + lastMessage = "A very very very very long message which suites on two lines", + avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem), + id = "!roomId:domain", + roomId = RoomId("!roomId:domain") + ), + RoomListRoomSummary( + name = "Room#2", + hasUnread = false, + timestamp = "14:16", + lastMessage = "A short message", + avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem), + id = "!roomId2:domain", + roomId = RoomId("!roomId2:domain") + ), + RoomListRoomSummaryPlaceholders.create("!roomId2:domain"), + RoomListRoomSummaryPlaceholders.create("!roomId3:domain"), + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt new file mode 100644 index 0000000000..657648df42 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import io.element.android.features.leaveroom.api.LeaveRoomView +import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView +import io.element.android.features.roomlist.impl.components.RequestVerificationHeader +import io.element.android.features.roomlist.impl.components.RoomListMenuAction +import io.element.android.features.roomlist.impl.components.RoomListTopBar +import io.element.android.features.roomlist.impl.components.RoomSummaryRow +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.search.RoomListSearchResultView +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.designsystem.R as DrawableR + +@Composable +fun RoomListView( + state: RoomListState, + onRoomClicked: (RoomId) -> Unit, + onSettingsClicked: () -> Unit, + onVerifyClicked: () -> Unit, + onCreateRoomClicked: () -> Unit, + onInvitesClicked: () -> Unit, + onRoomSettingsClicked: (roomId: RoomId) -> Unit, + onMenuActionClicked: (RoomListMenuAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) + Box { + fun onRoomLongClicked( + roomListRoomSummary: RoomListRoomSummary + ) { + state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary)) + } + + if (state.contextMenu is RoomListState.ContextMenu.Shown) { + RoomListContextMenu( + contextMenu = state.contextMenu, + eventSink = state.eventSink, + onRoomSettingsClicked = onRoomSettingsClicked, + ) + } + + LeaveRoomView(state = state.leaveRoomState) + + RoomListContent( + state = state, + onVerifyClicked = onVerifyClicked, + onRoomClicked = onRoomClicked, + onRoomLongClicked = { onRoomLongClicked(it) }, + onOpenSettings = onSettingsClicked, + onCreateRoomClicked = onCreateRoomClicked, + onInvitesClicked = onInvitesClicked, + onMenuActionClicked = onMenuActionClicked, + ) + // This overlaid view will only be visible when state.displaySearchResults is true + RoomListSearchResultView( + state = state, + onRoomClicked = onRoomClicked, + onRoomLongClicked = { onRoomLongClicked(it) }, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun RoomListContent( + state: RoomListState, + onVerifyClicked: () -> Unit, + onRoomClicked: (RoomId) -> Unit, + onRoomLongClicked: (RoomListRoomSummary) -> Unit, + onOpenSettings: () -> Unit, + onCreateRoomClicked: () -> Unit, + onInvitesClicked: () -> Unit, + onMenuActionClicked: (RoomListMenuAction) -> Unit, + modifier: Modifier = Modifier, +) { + fun onRoomClicked(room: RoomListRoomSummary) { + onRoomClicked(room.roomId) + } + + val appBarState = rememberTopAppBarState() + val lazyListState = rememberLazyListState() + + val visibleRange by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0 + val size = layoutInfo.visibleItemsInfo.size + firstItemIndex until firstItemIndex + size + } + } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState) + LogCompositions( + tag = "RoomListScreen", + msg = "Content" + ) + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange)) + return super.onPostFling(consumed, available) + } + } + } + + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + RoomListTopBar( + matrixUser = state.matrixUser, + areSearchResultsDisplayed = state.displaySearchResults, + onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, + onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, + onMenuActionClicked = onMenuActionClicked, + onOpenSettings = onOpenSettings, + scrollBehavior = scrollBehavior, + ) + }, + content = { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .nestedScroll(nestedScrollConnection), + state = lazyListState, + ) { + if (state.displayVerificationPrompt) { + item { + RequestVerificationHeader( + onVerifyClicked = onVerifyClicked, + onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } + ) + } + } + + if (state.invitesState != InvitesState.NoInvites) { + item { + InvitesEntryPointView(onInvitesClicked, state.invitesState) + } + } + + itemsIndexed( + items = state.roomList, + contentType = { _, room -> room.contentType() }, + ) { index, room -> + RoomSummaryRow( + room = room, + onClick = ::onRoomClicked, + onLongClick = onRoomLongClicked, + ) + if (index != state.roomList.lastIndex) { + Divider() + } + } + } + }, + floatingActionButton = { + FloatingActionButton( + // FIXME align on Design system theme + containerColor = MaterialTheme.colorScheme.primary, + onClick = onCreateRoomClicked + ) { + Icon( + // Correct icon alignment for better rendering. + modifier = Modifier.padding(start = 1.dp, bottom = 1.dp), + resourceId = DrawableR.drawable.ic_edit_square, + contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message) + ) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + ) + } + }, + ) +} + +internal fun RoomListRoomSummary.contentType() = isPlaceholder + +@Preview +@Composable +internal fun RoomListViewLightPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun RoomListViewDarkPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomListState) { + RoomListView( + state = state, + onRoomClicked = {}, + onSettingsClicked = {}, + onVerifyClicked = {}, + onCreateRoomClicked = {}, + onInvitesClicked = {}, + onRoomSettingsClicked = {}, + onMenuActionClicked = {}, + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt new file mode 100644 index 0000000000..16eea28f20 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.roomlist.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun RequestVerificationHeader( + onVerifyClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Surface( + modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row { + Text( + stringResource(R.string.session_verification_banner_title), + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyLgMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Start, + ) + Icon( + modifier = Modifier.clickable(onClick = onDismissClicked), + imageVector = Icons.Default.Close, + contentDescription = stringResource(CommonStrings.action_close) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + stringResource(R.string.session_verification_banner_message), + style = ElementTheme.typography.fontBodyMdRegular, + ) + Spacer(modifier = Modifier.height(12.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp), + onClick = onVerifyClicked, + ) { + Text( + stringResource(CommonStrings.action_continue), + style = ElementTheme.typography.aliasButtonText + ) + } + } + } + } +} + +@Preview +@Composable +internal fun PreviewRequestVerificationHeaderLight() { + ElementPreviewLight { + RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {}) + } +} + +@Preview +@Composable +internal fun PreviewRequestVerificationHeaderDark() { + ElementPreviewDark { + RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {}) + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt new file mode 100644 index 0000000000..9e598d8824 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListMenuAction.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.components + +enum class RoomListMenuAction { + InviteFriends, + ReportBug +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt new file mode 100644 index 0000000000..db0ea8c11d --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.components + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.roomlist.impl.R +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.DropdownMenu +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomListTopBar( + matrixUser: MatrixUser?, + areSearchResultsDisplayed: Boolean, + onFilterChanged: (String) -> Unit, + onToggleSearch: () -> Unit, + onMenuActionClicked: (RoomListMenuAction) -> Unit, + onOpenSettings: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, +) { + LogCompositions( + tag = "RoomListScreen", + msg = "TopBar" + ) + + fun closeFilter() { + onFilterChanged("") + } + + BackHandler(enabled = areSearchResultsDisplayed) { + closeFilter() + onToggleSearch() + } + + DefaultRoomListTopBar( + matrixUser = matrixUser, + onOpenSettings = onOpenSettings, + onSearchClicked = onToggleSearch, + onMenuActionClicked = onMenuActionClicked, + scrollBehavior = scrollBehavior, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DefaultRoomListTopBar( + matrixUser: MatrixUser?, + scrollBehavior: TopAppBarScrollBehavior, + onOpenSettings: () -> Unit, + onSearchClicked: () -> Unit, + onMenuActionClicked: (RoomListMenuAction) -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + MediumTopAppBar( + modifier = modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + title = { + val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5) + ElementTheme.typography.aliasScreenTitle + else + ElementTheme.typography.fontHeadingLgBold + Text( + style = fontStyle, + text = stringResource(id = R.string.screen_roomlist_main_space_title) + ) + }, + navigationIcon = { + if (matrixUser != null) { + IconButton( + modifier = Modifier.testTag(TestTags.homeScreenSettings), + onClick = onOpenSettings + ) { + val avatarData by remember { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + } + } + Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings)) + } + } + }, + actions = { + IconButton( + onClick = onSearchClicked, + ) { + Icon( + imageVector = Icons.Default.Search, + tint = ElementTheme.materialColors.secondary, + contentDescription = stringResource(CommonStrings.action_search), + ) + } + IconButton( + onClick = { showMenu = !showMenu } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + tint = ElementTheme.materialColors.secondary, + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClicked(RoomListMenuAction.InviteFriends) + }, + text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_invite)) }, + leadingIcon = { + Icon( + Icons.Outlined.Share, + tint = ElementTheme.materialColors.secondary, + contentDescription = null, + ) + } + ) + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClicked(RoomListMenuAction.ReportBug) + }, + text = { DropdownMenuItemText(stringResource(id = CommonStrings.common_report_a_bug)) }, + leadingIcon = { + Icon( + Icons.Outlined.BugReport, + tint = ElementTheme.materialColors.secondary, + contentDescription = null, + ) + } + ) + } + }, + scrollBehavior = scrollBehavior, + windowInsets = WindowInsets(0.dp), + ) +} + +@Preview +@Composable +internal fun DefaultRoomListTopBarLightPreview() = ElementPreviewLight { DefaultRoomListTopBarPreview() } + +@Preview +@Composable +internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRoomListTopBarPreview() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DefaultRoomListTopBarPreview() { + DefaultRoomListTopBar( + matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onSearchClicked = {}, + onMenuActionClicked = {}, + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt new file mode 100644 index 0000000000..0261f1bc16 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryPlaceholderRow.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme + +/** + * https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=6547%3A147623 + */ +@Composable +internal fun RoomSummaryPlaceholderRow( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(minHeight) + .padding(horizontal = 16.dp), + ) { + Box( + modifier = Modifier + .size(AvatarSize.RoomListItem.dp) + .align(Alignment.CenterVertically) + .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, top = 19.dp, end = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(22.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PlaceholderAtom(width = 40.dp, height = 7.dp) + Spacer(modifier = Modifier.width(7.dp)) + PlaceholderAtom(width = 45.dp, height = 7.dp) + Spacer(modifier = Modifier.weight(1f)) + PlaceholderAtom(width = 22.dp, height = 4.dp) + } + Row( + modifier = Modifier + .height(25.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PlaceholderAtom(width = 70.dp, height = 6.dp) + Spacer(modifier = Modifier.width(6.dp)) + PlaceholderAtom(width = 70.dp, height = 6.dp) + } + } + } +} + +@Preview +@Composable +internal fun RoomSummaryPlaceholderRowLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RoomSummaryPlaceholderRowDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + RoomSummaryPlaceholderRow() +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt new file mode 100644 index 0000000000..b32f169e6b --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.roomListRoomMessage +import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate +import io.element.android.libraries.designsystem.theme.roomListRoomName +import io.element.android.libraries.designsystem.theme.unreadIndicator +import io.element.android.libraries.theme.ElementTheme + +internal val minHeight = 84.dp + +@Composable +internal fun RoomSummaryRow( + room: RoomListRoomSummary, + onClick: (RoomListRoomSummary) -> Unit, + onLongClick: (RoomListRoomSummary) -> Unit, + modifier: Modifier = Modifier, +) { + if (room.isPlaceholder) { + RoomSummaryPlaceholderRow( + modifier = modifier, + ) + } else { + RoomSummaryRealRow( + room = room, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun RoomSummaryRealRow( + room: RoomListRoomSummary, + onClick: (RoomListRoomSummary) -> Unit, + onLongClick: (RoomListRoomSummary) -> Unit, + modifier: Modifier = Modifier, +) { + val clickModifier = Modifier.combinedClickable( + onClick = { onClick(room) }, + onLongClick = { onLongClick(room) }, + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() } + ) + + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .then(clickModifier) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 11.dp) + .height(IntrinsicSize.Min), + ) { + Avatar( + room + .avatarData, + modifier = Modifier + .align(Alignment.CenterVertically) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + NameAndTimestampRow(room = room) + } + Row(modifier = Modifier.fillMaxWidth()) { + LastMessageAndIndicatorRow(room = room) + } + } + } +} + +@Composable +private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) { + // Name + Text( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + style = ElementTheme.typography.fontBodyLgMedium, + text = room.name, + color = MaterialTheme.roomListRoomName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Timestamp + Text( + text = room.timestamp ?: "", + style = ElementTheme.typography.fontBodySmRegular, + color = if (room.hasUnread) { + ElementTheme.colors.unreadIndicator + } else { + MaterialTheme.roomListRoomMessageDate() + }, + ) +} + +@Composable +private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) { + // Last Message + val attributedLastMessage = (room.lastMessage as? AnnotatedString) + ?: AnnotatedString(room.lastMessage.orEmpty().toString()) + Text( + modifier = Modifier + .weight(1f) + .padding(end = 28.dp), + text = attributedLastMessage, + color = MaterialTheme.roomListRoomMessage(), + style = ElementTheme.typography.fontBodyMdRegular, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + // Unread + UnreadIndicatorAtom( + modifier = Modifier.padding(top = 3.dp), + isVisible = room.hasUnread, + ) +} + +val TextPlaceholderShape = PercentRectangleSizeShape(0.5f) + +class PercentRectangleSizeShape(private val percent: Float) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val halfPercent = percent / 2f + val path = Path().apply { + val rect = Rect( + 0f, + size.height * halfPercent, + size.width, + size.height - (size.height * halfPercent) + ) + addRect(rect) + close() + } + return Outline.Generic(path) + } +} + +@Preview +@Composable +internal fun RoomSummaryRowLightPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = + ElementPreviewLight { ContentToPreview(data) } + +@Preview +@Composable +internal fun RoomSummaryRowDarkPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = + ElementPreviewDark { ContentToPreview(data) } + +@Composable +private fun ContentToPreview(data: RoomListRoomSummary) { + RoomSummaryRow( + room = data, + onClick = {}, + onLongClick = {} + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt new file mode 100644 index 0000000000..3a89014799 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.invitelist.api.SeenInvitesStore +import io.element.android.features.roomlist.impl.InvitesState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummary +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultInviteStateDataSource @Inject constructor( + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, + private val coroutineDispatchers: CoroutineDispatchers, +) : InviteStateDataSource { + + @Composable + override fun inviteState(): InvitesState { + val invites by client + .roomSummaryDataSource + .inviteRooms() + .collectAsState() + + val seenInvites by seenInvitesStore + .seenRoomIds() + .collectAsState(initial = emptySet()) + + var state by remember { mutableStateOf(InvitesState.NoInvites) } + + LaunchedEffect(invites, seenInvites) { + withContext(coroutineDispatchers.computation) { + state = when { + invites.isEmpty() -> InvitesState.NoInvites + seenInvites.containsAll(invites.roomIds) -> InvitesState.SeenInvites + else -> InvitesState.NewInvites + } + } + } + + return state + } +} + +private val List<RoomSummary>.roomIds: Collection<RoomId> + get() = filterIsInstance<RoomSummary.Filled>().map { it.details.roomId } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt new file mode 100644 index 0000000000..f44ec1ea83 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/InviteStateDataSource.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.datasource + +import androidx.compose.runtime.Composable +import io.element.android.features.roomlist.impl.InvitesState + +interface InviteStateDataSource { + + @Composable + fun inviteState(): InvitesState + +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt new file mode 100644 index 0000000000..8602f15910 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.datasource + +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class RoomListDataSource @Inject constructor( + private val roomSummaryDataSource: RoomSummaryDataSource, + private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, + private val roomLastMessageFormatter: RoomLastMessageFormatter, + private val coroutineDispatchers: CoroutineDispatchers, +) { + + private val _filter = MutableStateFlow("") + private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf()) + private val _filteredRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf()) + + fun launchIn(coroutineScope: CoroutineScope) { + roomSummaryDataSource + .allRooms() + .onEach { roomSummaries -> + _allRooms.value = if (roomSummaries.isEmpty()) { + RoomListRoomSummaryPlaceholders.createFakeList(16) + } else { + mapRoomSummaries(roomSummaries) + }.toImmutableList() + } + .launchIn(coroutineScope) + + combine( + _filter, + _allRooms + ) { filterValue, allRoomsValue -> + when { + filterValue.isEmpty() -> emptyList() + else -> allRoomsValue.filter { it.name.contains(filterValue, ignoreCase = true) } + }.toImmutableList() + } + .onEach { + _filteredRooms.value = it + }.launchIn(coroutineScope) + } + + fun updateFilter(filterValue: String) { + _filter.value = filterValue + } + + val filter: StateFlow<String> = _filter + val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms + val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms + + private suspend fun mapRoomSummaries( + roomSummaries: List<RoomSummary> + ): List<RoomListRoomSummary> = withContext(coroutineDispatchers.computation) { + roomSummaries.map { roomSummary -> + when (roomSummary) { + is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) + is RoomSummary.Filled -> { + val avatarData = AvatarData( + id = roomSummary.identifier(), + name = roomSummary.details.name, + url = roomSummary.details.avatarURLString, + size = AvatarSize.RoomListItem, + ) + val roomIdentifier = roomSummary.identifier() + RoomListRoomSummary( + id = roomSummary.identifier(), + roomId = RoomId(roomIdentifier), + name = roomSummary.details.name, + hasUnread = roomSummary.details.unreadNotificationCount > 0, + timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage?.let { message -> + roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) + }.orEmpty(), + avatarData = avatarData, + ) + } + } + } + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt new file mode 100644 index 0000000000..8ba6c26c0f --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummary.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId + +@Immutable +data class RoomListRoomSummary constructor( + val id: String, + val roomId: RoomId, + val name: String = "", + val hasUnread: Boolean = false, + val timestamp: String? = null, + val lastMessage: CharSequence? = null, + val avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), + val isPlaceholder: Boolean = false, +) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt new file mode 100644 index 0000000000..111217d7a2 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryPlaceholders.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.model + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId + +object RoomListRoomSummaryPlaceholders { + + fun create(id: String): RoomListRoomSummary { + return RoomListRoomSummary( + id = id, + roomId = RoomId("!aRoom:domain"), + isPlaceholder = true, + name = "Short name", + timestamp = "hh:mm", + lastMessage = "Last message for placeholder", + avatarData = AvatarData(id, "S", size = AvatarSize.RoomListItem) + ) + } + + fun createFakeList(size: Int): List<RoomListRoomSummary> { + return mutableListOf<RoomListRoomSummary>().apply { + repeat(size) { + add(create("!fakeRoom$it:domain")) + } + } + } +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt new file mode 100644 index 0000000000..cd6ca21106 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.model + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.RoomId + +open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> { + override val values: Sequence<RoomListRoomSummary> + get() = sequenceOf( + aRoomListRoomSummary(), + aRoomListRoomSummary().copy(lastMessage = null), + aRoomListRoomSummary().copy(hasUnread = true), + aRoomListRoomSummary().copy(timestamp = "88:88"), + aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true), + aRoomListRoomSummary().copy(isPlaceholder = true, timestamp = "88:88"), + aRoomListRoomSummary().copy( + name = "A very long room name that should be truncated", + lastMessage = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt" + + " ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea com" + + "modo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + timestamp = "yesterday", + hasUnread = true, + ), + ) +} + +fun aRoomListRoomSummary() = RoomListRoomSummary( + id = "!roomId", + roomId = RoomId("!roomId:domain"), + name = "Room name", + hasUnread = false, + timestamp = null, + lastMessage = "Last message", + avatarData = AvatarData("!roomId", "Room name", size = AvatarSize.RoomListItem), + isPlaceholder = false, +) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt new file mode 100644 index 0000000000..1d8b6c2e8b --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearch.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.search + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import io.element.android.features.roomlist.impl.RoomListEvents +import io.element.android.features.roomlist.impl.RoomListState +import io.element.android.features.roomlist.impl.aRoomListState +import io.element.android.features.roomlist.impl.components.RoomSummaryRow +import io.element.android.features.roomlist.impl.contentType +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.modifiers.applyIf +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.copy +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun RoomListSearchResultView( + state: RoomListState, + onRoomClicked: (RoomId) -> Unit, + onRoomLongClicked: (RoomListRoomSummary) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = state.displaySearchResults, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = modifier + .applyIf(state.displaySearchResults, ifTrue = { + // Disable input interaction to underlying views + pointerInput(Unit) {} + }) + ) { + if (state.displaySearchResults) { + RoomListSearchResultContent( + state = state, + onRoomClicked = onRoomClicked, + onRoomLongClicked = onRoomLongClicked, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun RoomListSearchResultContent( + state: RoomListState, + onRoomClicked: (RoomId) -> Unit, + onRoomLongClicked: (RoomListRoomSummary) -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = MaterialTheme.colorScheme.tertiary + val strokeWidth = 1.dp + fun onBackButtonPressed() { + state.eventSink(RoomListEvents.ToggleSearchResults) + } + + fun onRoomClicked(room: RoomListRoomSummary) { + onRoomClicked(room.roomId) + } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + modifier = Modifier.drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth.value + ) + }, + navigationIcon = { BackButton(onClick = ::onBackButtonPressed) }, + title = { + val filter = state.filter.orEmpty() + val focusRequester = FocusRequester() + TextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + value = filter, + singleLine = true, + onValueChange = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + ), + trailingIcon = { + if (filter.isNotEmpty()) { + IconButton(onClick = { + state.eventSink(RoomListEvents.UpdateFilter("")) + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(CommonStrings.action_cancel) + ) + } + } + } + ) + + LaunchedEffect(state.displaySearchResults) { + if (state.displaySearchResults) { + focusRequester.requestFocus() + } + } + }, + windowInsets = TopAppBarDefaults.windowInsets.copy(top = 0) + ) + } + ) { padding -> + val lazyListState = rememberLazyListState() + val visibleRange by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + val firstItemIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: 0 + val size = layoutInfo.visibleItemsInfo.size + firstItemIndex until firstItemIndex + size + } + } + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange)) + return super.onPostFling(consumed, available) + } + } + } + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .nestedScroll(nestedScrollConnection), + state = lazyListState, + ) { + items( + items = state.filteredRoomList, + contentType = { room -> room.contentType() }, + ) { room -> + RoomSummaryRow( + room = room, + onClick = ::onRoomClicked, + onLongClick = onRoomLongClicked, + ) + } + } + } + } +} + +@Preview +@Composable +internal fun RoomListSearchResultContentLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RoomListSearchResultContentDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Preview +@Composable +internal fun ContentToPreview() { + RoomListSearchResultContent( + state = aRoomListState(), + onRoomClicked = {}, + onRoomLongClicked = {} + ) +} diff --git a/features/roomlist/impl/src/main/res/values-cs/translations.xml b/features/roomlist/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..d355d2c70c --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Vytvořte novou konverzaci nebo místnost"</string> + <string name="screen_roomlist_main_space_title">"Všechny chaty"</string> + <string name="session_verification_banner_message">"Zdá se, že používáte nové zařízení. Ověřte přihlášení, abyste měli přístup k zašifrovaným zprávám."</string> + <string name="session_verification_banner_title">"Přístup k historii zpráv"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-de/translations.xml b/features/roomlist/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..2ed1cd0263 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Ein neues Gespräch oder einen neuen Raum erstellen"</string> + <string name="screen_roomlist_main_space_title">"Alle Chats"</string> + <string name="session_verification_banner_message">"Es sieht so aus, als ob du ein neues Gerät verwendest. Verifiziere, dass du es bist, um auf deine verschlüsselten Nachrichten zuzugreifen."</string> + <string name="session_verification_banner_title">"Greife auf deine Nachrichten-Historie zu"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..899b1e2cac --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Crear una nueva conversación o sala"</string> + <string name="screen_roomlist_main_space_title">"Todos los chats"</string> + <string name="session_verification_banner_message">"Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados."</string> + <string name="session_verification_banner_title">"Accede a tu historial de mensajes"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-fr/translations.xml b/features/roomlist/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..a5217e1ad5 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Créer une nouvelle conversation ou un nouveau salon"</string> + <string name="screen_roomlist_main_space_title">"Tous les chats"</string> + <string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Lancez la vérification avec un autre appareil pour accéder à vos messages chiffrés à l’avenir."</string> + <string name="session_verification_banner_title">"Vérifier que c’est bien vous"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..cbe93e52d9 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Crea una nuova conversazione o stanza"</string> + <string name="screen_roomlist_main_space_title">"Tutte le conversazioni"</string> + <string name="session_verification_banner_message">"Sembra che tu stia utilizzando un nuovo dispositivo. Verifica di essere tu per accedere ai tuoi messaggi crittografati."</string> + <string name="session_verification_banner_title">"Accedi alla cronologia dei messaggi"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..b8ffc57090 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Creați o conversație sau o cameră nouă"</string> + <string name="screen_roomlist_main_space_title">"Toate conversatiile"</string> + <string name="session_verification_banner_message">"Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea pentru acces la mesajele dumneavoastră criptate."</string> + <string name="session_verification_banner_title">"Accesați istoricul mesajelor"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values-sk/translations.xml b/features/roomlist/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..250822f4c9 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Vytvorte novú konverzáciu alebo miestnosť"</string> + <string name="screen_roomlist_main_space_title">"Všetky konverzácie"</string> + <string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."</string> + <string name="session_verification_banner_title">"Overte, že ste to vy"</string> +</resources> diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..3a1c3cbad6 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string> + <string name="screen_roomlist_main_space_title">"All Chats"</string> + <string name="session_verification_banner_message">"Looks like you’re using a new device. Verify with another device to access your encrypted messages moving forwards."</string> + <string name="session_verification_banner_title">"Verify it’s you"</string> +</resources> diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt new file mode 100644 index 0000000000..0ead16da45 --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomPresenter +import io.element.android.features.leaveroom.fake.LeaveRoomPresenterFake +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource +import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource +import io.element.android.features.roomlist.impl.datasource.RoomListDataSource +import io.element.android.features.roomlist.impl.model.RoomListRoomSummary +import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter +import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomListPresenterTests { + + @Test + fun `present - should start with no user and then load user with success`() = runTest { + val presenter = createRoomListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.matrixUser).isNull() + val withUserState = awaitItem() + Truth.assertThat(withUserState.matrixUser).isNotNull() + Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) + Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) + Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL) + } + } + + @Test + fun `present - should start with no user and then load user with error`() = runTest { + val matrixClient = FakeMatrixClient( + userDisplayName = Result.failure(AN_EXCEPTION), + userAvatarURLString = Result.failure(AN_EXCEPTION), + ) + val presenter = createRoomListPresenter(matrixClient) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.matrixUser).isNull() + val withUserState = awaitItem() + Truth.assertThat(withUserState.matrixUser).isNotNull() + } + } + + @Test + fun `present - should filter room with success`() = runTest { + val presenter = createRoomListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val withUserState = awaitItem() + Truth.assertThat(withUserState.filter).isEqualTo("") + withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) + val withFilterState = awaitItem() + Truth.assertThat(withFilterState.filter).isEqualTo("t") + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - load 1 room with success`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource + ) + val presenter = createRoomListPresenter(matrixClient) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + // Room list is loaded with 16 placeholders + Truth.assertThat(initialState.roomList.size).isEqualTo(16) + Truth.assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue() + roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) + val withRoomState = awaitItem() + Truth.assertThat(withRoomState.roomList.size).isEqualTo(1) + Truth.assertThat(withRoomState.roomList.first()) + .isEqualTo(aRoomListRoomSummary) + } + } + + @Test + fun `present - load 1 room with success and filter rooms`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource + ) + val presenter = createRoomListPresenter(matrixClient) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) + skipItems(1) + val loadedState = awaitItem() + // Test filtering with result + loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) + skipItems(1) // Filter update + val withNotFilteredRoomState = awaitItem() + Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) + Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1) + Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first()) + .isEqualTo(aRoomListRoomSummary) + // Test filtering without result + withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) + skipItems(1) // Filter update + Truth.assertThat(awaitItem().filter).isEqualTo("tada") + Truth.assertThat(awaitItem().filteredRoomList).isEmpty() + } + } + + @Test + fun `present - update visible range`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource + ) + val presenter = createRoomListPresenter(matrixClient) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) + val loadedState = awaitItem() + // check initial value + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + // Test empty range + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + // Update visible range and check that range is transmitted to the SDK after computation + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(0, 20)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(0, 21)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(0, 49)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(29, 79)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(129, 179)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259))) + Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + .isEqualTo(IntRange(129, 279)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle DismissRequestVerificationPrompt`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val matrixClient = FakeMatrixClient( + roomSummaryDataSource = roomSummaryDataSource + ) + val presenter = createRoomListPresenter( + client = matrixClient, + sessionVerificationService = FakeSessionVerificationService().apply { + givenIsReady(true) + givenVerifiedStatus(SessionVerifiedStatus.NotVerified) + }, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val eventSink = awaitItem().eventSink + Truth.assertThat(awaitItem().displayVerificationPrompt).isTrue() + + eventSink(RoomListEvents.DismissRequestVerificationPrompt) + Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse() + } + } + + @Test + fun `present - sets invite state`() = runTest { + val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) + val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow) + val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites) + + inviteStateFlow.value = InvitesState.SeenInvites + Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites) + + inviteStateFlow.value = InvitesState.NewInvites + Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites) + + inviteStateFlow.value = InvitesState.NoInvites + Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites) + } + } + + @Test + fun `present - show context menu`() = runTest { + val presenter = createRoomListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + val summary = aRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + val shownState = awaitItem() + Truth.assertThat(shownState.contextMenu) + .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name)) + } + } + + @Test + fun `present - hide context menu`() = runTest { + val presenter = createRoomListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + + val initialState = awaitItem() + val summary = aRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + val shownState = awaitItem() + Truth.assertThat(shownState.contextMenu) + .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name)) + shownState.eventSink(RoomListEvents.HideContextMenu) + + val hiddenState = awaitItem() + Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden) + } + } + + @Test + fun `present - leave room calls into leave room presenter`() = runTest { + val leaveRoomPresenter = LeaveRoomPresenterFake() + val presenter = createRoomListPresenter(leaveRoomPresenter = leaveRoomPresenter) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID)) + Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createRoomListPresenter( + client: MatrixClient = FakeMatrixClient(), + sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), + networkMonitor: NetworkMonitor = FakeNetworkMonitor(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(), + leaveRoomPresenter: LeaveRoomPresenter = LeaveRoomPresenterFake(), + lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply { + givenFormat(A_FORMATTED_DATE) + }, + roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter() + ) = RoomListPresenter( + client = client, + sessionVerificationService = sessionVerificationService, + networkMonitor = networkMonitor, + snackbarDispatcher = snackbarDispatcher, + inviteStateDataSource = inviteStateDataSource, + leaveRoomPresenter = leaveRoomPresenter, + roomListDataSource = RoomListDataSource( + client.roomSummaryDataSource, + lastMessageTimestampFormatter, + roomLastMessageFormatter, + coroutineDispatchers = testCoroutineDispatchers() + ) + ) +} + +private const val A_FORMATTED_DATE = "formatted_date" + +private val aRoomListRoomSummary = RoomListRoomSummary( + id = A_ROOM_ID.value, + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + hasUnread = true, + timestamp = A_FORMATTED_DATE, + lastMessage = "", + avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), + isPlaceholder = false, +) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt new file mode 100644 index 0000000000..b67a6c6b43 --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.datasource + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.invitelist.test.FakeSeenInvitesStore +import io.element.android.features.roomlist.impl.InvitesState +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class DefaultInviteStateDataSourceTest { + + @Test + fun `emits NoInvites state if invites list is empty`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val seenStore = FakeSeenInvitesStore() + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) + + moleculeFlow(RecompositionClock.Immediate) { + dataSource.inviteState() + }.test { + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites) + } + } + + @Test + fun `emits NewInvites state if unseen invite exists`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val seenStore = FakeSeenInvitesStore() + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) + + moleculeFlow(RecompositionClock.Immediate) { + dataSource.inviteState() + }.test { + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) + } + } + + @Test + fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val seenStore = FakeSeenInvitesStore() + seenStore.publishRoomIds(setOf(A_ROOM_ID)) + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) + + moleculeFlow(RecompositionClock.Immediate) { + dataSource.inviteState() + }.test { + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) + } + } + + @Test + fun `emits SeenInvites state if invite exists in seen store`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val seenStore = FakeSeenInvitesStore() + seenStore.publishRoomIds(setOf(A_ROOM_ID)) + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) + + moleculeFlow(RecompositionClock.Immediate) { + dataSource.inviteState() + }.test { + skipItems(1) + + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites) + } + } + + @Test + fun `emits new state in response to upstream events`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val seenStore = FakeSeenInvitesStore() + val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) + + moleculeFlow(RecompositionClock.Immediate) { + dataSource.inviteState() + }.test { + // Initially there are no invites + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites) + + // When a single invite is received, state should be NewInvites + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) + + // If that invite is marked as seen, then the state becomes SeenInvites + seenStore.publishRoomIds(setOf(A_ROOM_ID)) + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites) + + // Another new invite resets it to NewInvites + roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) + + // All of the invites going away reverts to NoInvites + roomSummaryDataSource.postInviteRooms(emptyList()) + skipItems(1) + Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites) + } + } +} diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt new file mode 100644 index 0000000000..7ea2d7821d --- /dev/null +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/FakeInviteDataSource.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.roomlist.impl.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import io.element.android.features.roomlist.impl.InvitesState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class FakeInviteDataSource( + private val flow: Flow<InvitesState> = flowOf() +) : InviteStateDataSource { + + @Composable + override fun inviteState(): InvitesState { + val state = flow.collectAsState(initial = InvitesState.NoInvites) + return state.value + } +} diff --git a/features/verifysession/api/build.gradle.kts b/features/verifysession/api/build.gradle.kts new file mode 100644 index 0000000000..65eec740e5 --- /dev/null +++ b/features/verifysession/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.verifysession.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt new file mode 100644 index 0000000000..933ca1994f --- /dev/null +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/VerifySessionEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface VerifySessionEntryPoint : SimpleFeatureEntryPoint diff --git a/features/verifysession/impl/.gitignore b/features/verifysession/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/verifysession/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts new file mode 100644 index 0000000000..cba9f2890e --- /dev/null +++ b/features/verifysession/impl/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.features.verifysession.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(libs.statemachine) + api(projects.features.verifysession.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + ksp(libs.showkase.processor) +} diff --git a/features/verifysession/impl/consumer-rules.pro b/features/verifysession/impl/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt new file mode 100644 index 0000000000..da8c22e756 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/DefaultVerifySessionEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.verifysession.api.VerifySessionEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode<VerifySelfSessionNode>(buildContext) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt new file mode 100644 index 0000000000..2eb9063e3d --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class VerifySelfSessionNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List<Plugin>, + private val presenter: VerifySelfSessionPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + VerifySelfSessionView( + state = state, + modifier = modifier, + goBack = { navigateUp() } + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt new file mode 100644 index 0000000000..689ff3a154 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.freeletics.flowredux.compose.rememberStateAndDispatch +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent +import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState + +class VerifySelfSessionPresenter @Inject constructor( + private val sessionVerificationService: SessionVerificationService, + private val stateMachine: VerifySelfSessionStateMachine, +) : Presenter<VerifySelfSessionState> { + + @Composable + override fun present(): VerifySelfSessionState { + LaunchedEffect(Unit) { + // Force reset, just in case the service was left in a broken state + sessionVerificationService.reset() + } + val stateAndDispatch = stateMachine.rememberStateAndDispatch() + val verificationFlowStep by remember { + derivedStateOf { stateAndDispatch.state.value.toVerificationStep() } + } + // Start this after observing state machine + LaunchedEffect(Unit) { + observeVerificationService() + } + + fun handleEvents(event: VerifySelfSessionViewEvents) { + when (event) { + VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification) + VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification) + VerifySelfSessionViewEvents.Restart -> stateAndDispatch.dispatchAction(StateMachineEvent.Restart) + VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) + VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) + VerifySelfSessionViewEvents.CancelAndClose -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) + } + } + return VerifySelfSessionState( + verificationFlowStep = verificationFlowStep, + eventSink = ::handleEvents, + ) + } + + private fun StateMachineState?.toVerificationStep(): VerifySelfSessionState.VerificationStep = + when (val machineState = this) { + StateMachineState.Initial, null -> { + VerifySelfSessionState.VerificationStep.Initial + } + StateMachineState.RequestingVerification, + StateMachineState.StartingSasVerification, + StateMachineState.SasVerificationStarted, + StateMachineState.Canceling -> { + VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse + } + + StateMachineState.VerificationRequestAccepted -> { + VerifySelfSessionState.VerificationStep.Ready + } + + StateMachineState.Canceled -> { + VerifySelfSessionState.VerificationStep.Canceled + } + + is StateMachineState.Verifying -> { + val async = when (machineState) { + is StateMachineState.Verifying.Replying -> Async.Loading() + else -> Async.Uninitialized + } + VerifySelfSessionState.VerificationStep.Verifying(machineState.emojis, async) + } + + StateMachineState.Completed -> { + VerifySelfSessionState.VerificationStep.Completed + } + } + + private fun CoroutineScope.observeVerificationService() { + sessionVerificationService.verificationFlowState.onEach { verificationAttemptState -> + when (verificationAttemptState) { + VerificationFlowState.Initial -> stateMachine.dispatch(VerifySelfSessionStateMachine.Event.Restart) + VerificationFlowState.AcceptedVerificationRequest -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptVerificationRequest) + } + VerificationFlowState.StartedSasVerification -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidStartSasVerification) + } + is VerificationFlowState.ReceivedVerificationData -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidReceiveChallenge(verificationAttemptState.emoji)) + } + VerificationFlowState.Finished -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidAcceptChallenge) + } + VerificationFlowState.Canceled -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidCancel) + } + VerificationFlowState.Failed -> { + stateMachine.dispatch(VerifySelfSessionStateMachine.Event.DidFail) + } + } + }.launchIn(this) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt new file mode 100644 index 0000000000..752cf942c1 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionState.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +@Immutable +data class VerifySelfSessionState( + val verificationFlowStep: VerificationStep, + val eventSink: (VerifySelfSessionViewEvents) -> Unit, +) { + + @Stable + sealed interface VerificationStep { + object Initial : VerificationStep + object Canceled : VerificationStep + object AwaitingOtherDeviceResponse : VerificationStep + object Ready : VerificationStep + data class Verifying(val emojiList: List<VerificationEmoji>, val state: Async<Unit>) : VerificationStep + object Completed : VerificationStep + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt new file mode 100644 index 0000000000..29818197e0 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateMachine.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:Suppress("WildcardImport") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl + +import com.freeletics.flowredux.dsl.FlowReduxStateMachine +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject +import com.freeletics.flowredux.dsl.State as MachineState + +class VerifySelfSessionStateMachine @Inject constructor( + private val sessionVerificationService: SessionVerificationService, +) : FlowReduxStateMachine<VerifySelfSessionStateMachine.State, VerifySelfSessionStateMachine.Event>( + initialState = State.Initial +) { + + init { + spec { + inState<State.Initial> { + on { _: Event.RequestVerification, state: MachineState<State.Initial> -> + state.override { State.RequestingVerification } + } + on { _: Event.StartSasVerification, state: MachineState<State.Initial> -> + state.override { State.StartingSasVerification } + } + } + inState<State.RequestingVerification> { + onEnterEffect { + sessionVerificationService.requestVerification() + } + on { _: Event.DidAcceptVerificationRequest, state: MachineState<State.RequestingVerification> -> + state.override { State.VerificationRequestAccepted } + } + on { _: Event.DidFail, state: MachineState<State.RequestingVerification> -> + state.override { State.Initial } + } + } + inState<State.StartingSasVerification> { + onEnterEffect { + sessionVerificationService.startVerification() + } + } + inState<State.VerificationRequestAccepted> { + on { _: Event.StartSasVerification, state: MachineState<State.VerificationRequestAccepted> -> + state.override { State.StartingSasVerification } + } + } + inState<State.Canceled> { + on { _: Event.Restart, state: MachineState<State.Canceled> -> + state.override { State.RequestingVerification } + } + } + inState<State.SasVerificationStarted> { + on { event: Event.DidReceiveChallenge, state: MachineState<State.SasVerificationStarted> -> + state.override { State.Verifying.ChallengeReceived(event.emojis) } + } + } + inState<State.Verifying.ChallengeReceived> { + on { _: Event.AcceptChallenge, state: MachineState<State.Verifying.ChallengeReceived> -> + state.override { State.Verifying.Replying(state.snapshot.emojis, accept = true) } + } + on { _: Event.DeclineChallenge, state: MachineState<State.Verifying.ChallengeReceived> -> + state.override { State.Verifying.Replying(state.snapshot.emojis, accept = false) } + } + } + inState<State.Verifying.Replying> { + onEnterEffect { state -> + if (state.accept) { + sessionVerificationService.approveVerification() + } else { + sessionVerificationService.declineVerification() + } + } + on { _: Event.DidAcceptChallenge, state: MachineState<State.Verifying.Replying> -> + state.override { State.Completed } + } + } + inState<State.Canceling> { + onEnterEffect { + sessionVerificationService.cancelVerification() + } + } + inState { + on { _: Event.DidStartSasVerification, state: MachineState<State> -> + state.override { State.SasVerificationStarted } + } + on { _: Event.Cancel, state: MachineState<State> -> + if (state.snapshot in sequenceOf( + State.Initial, + State.Completed, + State.Canceled + )) { + state.noChange() + } else { + state.override { State.Canceling } + } + } + on { _: Event.DidCancel, state: MachineState<State> -> + state.override { State.Canceled } + } + on { _: Event.DidFail, state: MachineState<State> -> + state.override { State.Canceled } + } + } + } + } + + sealed interface State { + /** The initial state, before verification started. */ + object Initial : State + + /** Waiting for verification acceptance. */ + object RequestingVerification : State + + /** Verification request accepted. Waiting for start. */ + object VerificationRequestAccepted : State + + /** Waiting for SaS verification start. */ + object StartingSasVerification : State + + /** A SaS verification flow has been started. */ + object SasVerificationStarted : State + + sealed class Verifying(open val emojis: List<VerificationEmoji>) : State { + /** Verification accepted and emojis received. */ + data class ChallengeReceived(override val emojis: List<VerificationEmoji>) : Verifying(emojis) + + /** Replying to a verification challenge. */ + data class Replying(override val emojis: List<VerificationEmoji>, val accept: Boolean) : Verifying(emojis) + } + + /** The verification is being canceled. */ + object Canceling : State + + /** The verification has been canceled, remotely or locally. */ + object Canceled : State + + /** Verification successful. */ + object Completed : State + } + + sealed interface Event { + /** Request verification. */ + object RequestVerification : Event + + /** The current verification request has been accepted. */ + object DidAcceptVerificationRequest : Event + + /** Start a SaS verification flow. */ + object StartSasVerification : Event + + /** Started a SaS verification flow. */ + object DidStartSasVerification : Event + + /** Has received emojis. */ + data class DidReceiveChallenge(val emojis: List<VerificationEmoji>) : Event + + /** Emojis match. */ + object AcceptChallenge : Event + + /** Emojis do not match. */ + object DeclineChallenge : Event + + /** Remote accepted challenge. */ + object DidAcceptChallenge : Event + + /** Request cancellation. */ + object Cancel : Event + + /** Verification cancelled. */ + object DidCancel : Event + + /** Request failed. */ + object DidFail : Event + + /** Restart the verification flow. */ + object Restart : Event + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt new file mode 100644 index 0000000000..54a59dee01 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionStateProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> { + override val values: Sequence<VerifySelfSessionState> + get() = sequenceOf( + aVerifySelfSessionState(), + aVerifySelfSessionState().copy( + verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse + ), + aVerifySelfSessionState().copy( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Uninitialized) + ), + aVerifySelfSessionState().copy( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Loading()) + ), + aVerifySelfSessionState().copy( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled + ), + aVerifySelfSessionState().copy( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Ready + ), + // Add other state here + ) +} + +fun aVerifySelfSessionState() = VerifySelfSessionState( + verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial, + eventSink = {}, +) + +fun aVerificationEmojiList() = listOf( + VerificationEmoji("🍕", "Pizza"), + VerificationEmoji("🚀", "Rocket"), + VerificationEmoji("🚀", "Rocket"), + VerificationEmoji("🗺️", "Map"), + VerificationEmoji("🎳", "Bowling"), + VerificationEmoji("🎳", "Bowling"), + VerificationEmoji("📌", "Pin"), +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt new file mode 100644 index 0000000000..77984a3bb2 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.button.ButtonWithProgress +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep + +@Composable +fun VerifySelfSessionView( + state: VerifySelfSessionState, + modifier: Modifier = Modifier, + goBack: () -> Unit, +) { + fun goBackAndCancelIfNeeded() { + state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) + goBack() + } + if (state.verificationFlowStep is FlowStep.Completed) { + goBack() + } + BackHandler { + goBackAndCancelIfNeeded() + } + val verificationFlowStep = state.verificationFlowStep + val buttonsVisible by remember(verificationFlowStep) { + derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed } + } + HeaderFooterPage( + modifier = modifier, + header = { + HeaderContent(verificationFlowStep = verificationFlowStep) + }, + footer = { + if (buttonsVisible) { + BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded) + } + } + ) { + Content(flowState = verificationFlowStep) + } +} + +@Composable +internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier = Modifier) { + val iconResourceId = when (verificationFlowStep) { + FlowStep.Initial -> R.drawable.ic_verification_devices + FlowStep.Canceled -> R.drawable.ic_verification_warning + FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting + FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji + } + val titleTextId = when (verificationFlowStep) { + FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_title + FlowStep.Canceled -> R.string.screen_session_verification_cancelled_title + FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_title + FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_title + } + val subtitleTextId = when (verificationFlowStep) { + FlowStep.Initial -> R.string.screen_session_verification_open_existing_session_subtitle + FlowStep.Canceled -> R.string.screen_session_verification_cancelled_subtitle + FlowStep.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_to_accept_subtitle + FlowStep.Ready, is FlowStep.Verifying, FlowStep.Completed -> R.string.screen_session_verification_compare_emojis_subtitle + } + + IconTitleSubtitleMolecule( + modifier = modifier.padding(top = 60.dp), + iconResourceId = iconResourceId, + title = stringResource(id = titleTextId), + subTitle = stringResource(id = subtitleTextId) + ) +} + +@Composable +internal fun Content(flowState: FlowStep, modifier: Modifier = Modifier) { + Column(modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center) { + when (flowState) { + FlowStep.Initial, FlowStep.Ready, FlowStep.Canceled, FlowStep.Completed -> Unit + FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting() + is FlowStep.Verifying -> ContentVerifying(flowState) + } + } +} + +@Composable +internal fun ContentWaiting(modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } +} + +@Composable +internal fun ContentVerifying(verificationFlowStep: FlowStep.Verifying, modifier: Modifier = Modifier) { + // We want each row to have up to 4 emojis + val rows = verificationFlowStep.emojiList.chunked(4) + Column(modifier = modifier.fillMaxWidth()) { + for ((rowIndex, emojis) in rows.withIndex()) { + // Vertical spacing between rows + if (rowIndex > 0) { + Spacer(modifier = Modifier.height(40.dp)) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + for (emoji in emojis) { + EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp)) + } + } + } + } +} + +@Composable +internal fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Text( + text = emoji.code, + style = ElementTheme.typography.fontBodyMdRegular.copy(fontSize = 34.sp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + emoji.name, + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) { + val verificationViewState = screenState.verificationFlowStep + val eventSink = screenState.eventSink + + val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is Async.Loading<Unit> + val positiveButtonTitle = when (verificationViewState) { + FlowStep.Initial -> R.string.screen_session_verification_positive_button_initial + FlowStep.Canceled -> R.string.screen_session_verification_positive_button_canceled + is FlowStep.Verifying -> { + if (isVerifying) { + R.string.screen_session_verification_positive_button_verifying_ongoing + } else { + R.string.screen_session_verification_they_match + } + } + FlowStep.Ready -> R.string.screen_session_verification_positive_button_ready + else -> null + } + val negativeButtonTitle = when (verificationViewState) { + FlowStep.Initial -> CommonStrings.action_cancel + FlowStep.Canceled -> CommonStrings.action_cancel + is FlowStep.Verifying -> R.string.screen_session_verification_they_dont_match + else -> null + } + val negativeButtonEnabled = !isVerifying + + val positiveButtonEvent = when (verificationViewState) { + FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification + FlowStep.Ready -> VerifySelfSessionViewEvents.StartSasVerification + is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null + FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart + else -> null + } + + val negativeButtonCallback: () -> Unit = when (verificationViewState) { + is FlowStep.Verifying -> { + { eventSink(VerifySelfSessionViewEvents.DeclineVerification) } + } + else -> goBack + } + + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 20.dp) + ) { + ButtonWithProgress( + text = positiveButtonTitle?.let { stringResource(it) }, + showProgress = isVerifying, + modifier = Modifier.fillMaxWidth(), + onClick = { positiveButtonEvent?.let { eventSink(it) } } + ) + if (negativeButtonTitle != null) { + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = negativeButtonCallback, + enabled = negativeButtonEnabled, + ) { + Text( + text = stringResource(negativeButtonTitle), + style = ElementTheme.typography.aliasButtonText, + ) + } + } + } +} + +@Preview +@Composable +fun VerifySelfSessionViewLightPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun VerifySelfSessionViewDarkPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: VerifySelfSessionState) { + VerifySelfSessionView( + state = state, + goBack = {}, + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt new file mode 100644 index 0000000000..9c0fedada4 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +sealed interface VerifySelfSessionViewEvents { + object RequestVerification: VerifySelfSessionViewEvents + object StartSasVerification: VerifySelfSessionViewEvents + object Restart: VerifySelfSessionViewEvents + object ConfirmVerification: VerifySelfSessionViewEvents + object DeclineVerification: VerifySelfSessionViewEvents + object CancelAndClose: VerifySelfSessionViewEvents +} diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml new file mode 100644 index 0000000000..8ae6dd30fa --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_devices.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="@android:color/white" + android:pathData="M8.3,35.5V11Q8.3,9.8 9.2,8.9Q10.1,8 11.3,8H40.8Q41.45,8 41.875,8.425Q42.3,8.85 42.3,9.5Q42.3,10.15 41.875,10.575Q41.45,11 40.8,11H11.3Q11.3,11 11.3,11Q11.3,11 11.3,11V35.5H20.75Q21.7,35.5 22.35,36.15Q23,36.8 23,37.75Q23,38.7 22.35,39.35Q21.7,40 20.75,40H6.25Q5.3,40 4.65,39.35Q4,38.7 4,37.75Q4,36.8 4.65,36.15Q5.3,35.5 6.25,35.5ZM27.95,40Q27.15,40 26.575,39.4Q26,38.8 26,37.8V15.95Q26,15.15 26.575,14.575Q27.15,14 27.95,14H41.55Q42.55,14 43.275,14.575Q44,15.15 44,15.95V37.8Q44,38.8 43.275,39.4Q42.55,40 41.55,40ZM29,35.5H41V17H29Z"/> +</vector> diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml new file mode 100644 index 0000000000..82583a4011 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_emoji.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="@android:color/white" + android:pathData="M31.3,21.35Q32.45,21.35 33.225,20.575Q34,19.8 34,18.65Q34,17.5 33.225,16.725Q32.45,15.95 31.3,15.95Q30.15,15.95 29.375,16.725Q28.6,17.5 28.6,18.65Q28.6,19.8 29.375,20.575Q30.15,21.35 31.3,21.35ZM16.7,21.35Q17.85,21.35 18.625,20.575Q19.4,19.8 19.4,18.65Q19.4,17.5 18.625,16.725Q17.85,15.95 16.7,15.95Q15.55,15.95 14.775,16.725Q14,17.5 14,18.65Q14,19.8 14.775,20.575Q15.55,21.35 16.7,21.35ZM24,34.95Q26.85,34.95 29.375,33.6Q31.9,32.25 33.35,29.85Q33.75,29.25 33.425,28.8Q33.1,28.35 32.4,28.35H15.6Q14.9,28.35 14.6,28.8Q14.3,29.25 14.7,29.85Q16.15,32.25 18.65,33.6Q21.15,34.95 24,34.95ZM24,44Q19.9,44 16.25,42.425Q12.6,40.85 9.875,38.125Q7.15,35.4 5.575,31.75Q4,28.1 4,23.95Q4,19.85 5.575,16.2Q7.15,12.55 9.875,9.85Q12.6,7.15 16.25,5.575Q19.9,4 24.05,4Q28.15,4 31.8,5.575Q35.45,7.15 38.15,9.85Q40.85,12.55 42.425,16.2Q44,19.85 44,24Q44,28.1 42.425,31.75Q40.85,35.4 38.15,38.125Q35.45,40.85 31.8,42.425Q28.15,44 24,44ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24ZM24,41Q31.1,41 36.05,36.025Q41,31.05 41,24Q41,16.9 36.05,11.95Q31.1,7 24,7Q16.95,7 11.975,11.95Q7,16.9 7,24Q7,31.05 11.975,36.025Q16.95,41 24,41Z"/> +</vector> diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml new file mode 100644 index 0000000000..5b9f2e3cfc --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_waiting.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="@android:color/white" + android:pathData="M15.8,41H32.2V34.65Q32.2,31.15 29.825,28.625Q27.45,26.1 24,26.1Q20.55,26.1 18.175,28.625Q15.8,31.15 15.8,34.65ZM38.5,44H9.5Q8.85,44 8.425,43.575Q8,43.15 8,42.5Q8,41.85 8.425,41.425Q8.85,41 9.5,41H12.8V34.65Q12.8,31.15 14.625,28.225Q16.45,25.3 19.7,24Q16.45,22.7 14.625,19.75Q12.8,16.8 12.8,13.3V7H9.5Q8.85,7 8.425,6.575Q8,6.15 8,5.5Q8,4.85 8.425,4.425Q8.85,4 9.5,4H38.5Q39.15,4 39.575,4.425Q40,4.85 40,5.5Q40,6.15 39.575,6.575Q39.15,7 38.5,7H35.2V13.3Q35.2,16.8 33.35,19.75Q31.5,22.7 28.3,24Q31.55,25.3 33.375,28.225Q35.2,31.15 35.2,34.65V41H38.5Q39.15,41 39.575,41.425Q40,41.85 40,42.5Q40,43.15 39.575,43.575Q39.15,44 38.5,44Z"/> +</vector> diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml new file mode 100644 index 0000000000..882ac62cd7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_warning.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportWidth="48" + android:viewportHeight="48"> + <path + android:fillColor="@android:color/white" + android:pathData="M24,24.6Q24.65,24.6 25.075,24.175Q25.5,23.75 25.5,23.1V15.75Q25.5,15.1 25.075,14.675Q24.65,14.25 24,14.25Q23.35,14.25 22.925,14.675Q22.5,15.1 22.5,15.75V23.1Q22.5,23.75 22.925,24.175Q23.35,24.6 24,24.6ZM24,31.3Q24.7,31.3 25.2,30.8Q25.7,30.3 25.7,29.6Q25.7,28.9 25.2,28.4Q24.7,27.9 24,27.9Q23.3,27.9 22.8,28.4Q22.3,28.9 22.3,29.6Q22.3,30.3 22.8,30.8Q23.3,31.3 24,31.3ZM24,43.85Q23.8,43.85 23.625,43.825Q23.45,43.8 23.3,43.75Q16.6,41.75 12.3,35.525Q8,29.3 8,21.85V12.05Q8,11.1 8.55,10.325Q9.1,9.55 9.95,9.2L22.95,4.35Q23.5,4.15 24,4.15Q24.5,4.15 25.05,4.35L38.05,9.2Q38.9,9.55 39.45,10.325Q40,11.1 40,12.05V21.85Q40,29.3 35.7,35.525Q31.4,41.75 24.7,43.75Q24.7,43.75 24,43.85ZM24,40.85Q29.75,38.95 33.375,33.675Q37,28.4 37,21.85V12.05Q37,12.05 37,12.05Q37,12.05 37,12.05L24,7.15Q24,7.15 24,7.15Q24,7.15 24,7.15L11,12.05Q11,12.05 11,12.05Q11,12.05 11,12.05V21.85Q11,28.4 14.625,33.675Q18.25,38.95 24,40.85ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Z"/> +</vector> diff --git a/features/verifysession/impl/src/main/res/values-cs/translations.xml b/features/verifysession/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..6bf8db5ac0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Něco není v pořádku. Buď vypršel časový limit požadavku, nebo byl požadavek zamítnut."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na jiné relaci."</string> + <string name="screen_session_verification_compare_emojis_title">"Porovnání emotikonů"</string> + <string name="screen_session_verification_complete_subtitle">"Vaše nová relace je nyní ověřena. Má přístup k vašim zašifrovaným zprávám a ostatní uživatelé ji uvidí jako důvěryhodnou."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Pro přístup k historii zašifrovaných zpráv prokažte, že jste to vy."</string> + <string name="screen_session_verification_open_existing_session_title">"Otevřete existující relaci"</string> + <string name="screen_session_verification_positive_button_canceled">"Opakovat ověření"</string> + <string name="screen_session_verification_positive_button_initial">"Jsem připraven"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Čekání na shodu"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Porovnejte jedinečné emotikony a ujistěte se, že jsou zobrazeny ve stejném pořadí."</string> + <string name="screen_session_verification_they_dont_match">"Neshodují se"</string> + <string name="screen_session_verification_they_match">"Shodují se"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Pro pokračování přijměte požadavek na zahájení ověření v jiné relaci."</string> + <string name="screen_session_verification_waiting_to_accept_title">"Čekání na přijetí žádosti"</string> + <string name="screen_session_verification_cancelled_title">"Ověření zrušeno"</string> + <string name="screen_session_verification_positive_button_ready">"Začít"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-de/translations.xml b/features/verifysession/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..f5f149cfd9 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Etwas scheint nicht zu stimmen. Entweder ist die Antwortzeit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Bestätige, dass die folgenden Emojis mit denen deiner anderen Sitzung übereinstimmen."</string> + <string name="screen_session_verification_compare_emojis_title">"Emojis vergleichen"</string> + <string name="screen_session_verification_complete_subtitle">"Deine neue Sitzung ist jetzt verifiziert. Sie hat Zugriff auf deine verschlüsselten Nachrichten und andere Benutzer werden sie als vertrauenswürdig sehen."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Beweise, dass du es bist, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen."</string> + <string name="screen_session_verification_open_existing_session_title">"Eine bestehende Sitzung öffnen"</string> + <string name="screen_session_verification_positive_button_canceled">"Verifizierung erneut versuchen"</string> + <string name="screen_session_verification_positive_button_initial">"Ich bin bereit"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Warten auf Übereinstimmung"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Vergleiche die einzigartigen Emojis und achte darauf, dass sie in derselben Reihenfolge erscheinen."</string> + <string name="screen_session_verification_they_dont_match">"Sie stimmen nicht überein"</string> + <string name="screen_session_verification_they_match">"Sie stimmen überein"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Akzeptiere die Aufforderung zum Starten des Verifizierungsprozesses in deiner anderen Sitzung, um fortzufahren."</string> + <string name="screen_session_verification_waiting_to_accept_title">"Warten auf die Annahme der Anfrage"</string> + <string name="screen_session_verification_cancelled_title">"Verifizierung abgebrochen"</string> + <string name="screen_session_verification_positive_button_ready">"Starten"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..386ecfc37c --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión."</string> + <string name="screen_session_verification_compare_emojis_title">"Comparar emojis"</string> + <string name="screen_session_verification_complete_subtitle">"Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Demuestra que eres tú para acceder a tu historial de mensajes cifrados."</string> + <string name="screen_session_verification_open_existing_session_title">"Abrir una sesión existente"</string> + <string name="screen_session_verification_positive_button_canceled">"Reintentar la verificación"</string> + <string name="screen_session_verification_positive_button_initial">"Estoy listo"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Esperando a que coincida"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Compara los emoji, asegurándote de que aparecen en el mismo orden."</string> + <string name="screen_session_verification_they_dont_match">"No coinciden"</string> + <string name="screen_session_verification_they_match">"Coinciden"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Acepta la solicitud para iniciar el proceso de verificación en tu otra sesión para continuar."</string> + <string name="screen_session_verification_waiting_to_accept_title">"A la espera de aceptar la solicitud"</string> + <string name="screen_session_verification_cancelled_title">"Verificación cancelada"</string> + <string name="screen_session_verification_positive_button_ready">"Comenzar"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..dd0bb56708 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Quelque chose ne semble pas normal. Soit la demande a dépassé le temps imparti, soit elle a été refusée."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Confirmez que les emojis ci-dessous correspondent à ceux affichés sur votre autre session."</string> + <string name="screen_session_verification_compare_emojis_title">"Comparez les émojis"</string> + <string name="screen_session_verification_complete_subtitle">"Votre nouvelle session est désormais vérifiée. Elle a accès à vos messages chiffrés et les autres utilisateurs la verront identifiée comme fiable."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Prouvez qu\'il s\'agit bien de vous pour accéder à l\'historique de vos messages chiffrés."</string> + <string name="screen_session_verification_open_existing_session_title">"Ouvrir une session existante"</string> + <string name="screen_session_verification_positive_button_canceled">"Réessayer la vérification"</string> + <string name="screen_session_verification_positive_button_initial">"Je suis prêt.e"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"En attente de correspondance"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Comparez les emoji uniques en veillant à ce qu\'ils apparaissent dans le même ordre."</string> + <string name="screen_session_verification_they_dont_match">"Ils ne correspondent pas"</string> + <string name="screen_session_verification_they_match">"Ils correspondent"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Pour continuer, acceptez la demande de lancement de la procédure de vérification dans votre autre session."</string> + <string name="screen_session_verification_waiting_to_accept_title">"En attente d\'acceptation de la demande"</string> + <string name="screen_session_verification_cancelled_title">"Vérification annulée"</string> + <string name="screen_session_verification_positive_button_ready">"Démarrer"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..7a6765adbf --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Verifica che gli emoji sottostanti corrispondano a quelli mostrati nell\'altra sessione."</string> + <string name="screen_session_verification_compare_emojis_title">"Confronta le emoji"</string> + <string name="screen_session_verification_complete_subtitle">"La tua nuova sessione è ora verificata. Ha accesso ai tuoi messaggi crittografati e gli altri utenti la vedranno come attendibile."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Dimostra la tua identità per accedere alla cronologia dei messaggi crittografati."</string> + <string name="screen_session_verification_open_existing_session_title">"Apri una sessione esistente"</string> + <string name="screen_session_verification_positive_button_canceled">"Riprova la verifica"</string> + <string name="screen_session_verification_positive_button_initial">"Sono pronto"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"In attesa di un riscontro"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Confronta le emoji uniche, assicurandoti che appaiano nello stesso ordine."</string> + <string name="screen_session_verification_they_dont_match">"Non corrispondono"</string> + <string name="screen_session_verification_they_match">"Corrispondono"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Accetta la richiesta di avviare il processo di verifica nell\'altra sessione per continuare."</string> + <string name="screen_session_verification_waiting_to_accept_title">"In attesa di accettare la richiesta"</string> + <string name="screen_session_verification_cancelled_title">"Verifica annullata"</string> + <string name="screen_session_verification_positive_button_ready">"Inizia"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..e392438bcd --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune."</string> + <string name="screen_session_verification_compare_emojis_title">"Comparați emoticoanele"</string> + <string name="screen_session_verification_complete_subtitle">"Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar alți utilizatori vă vor vedea ca fiind de încredere."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Demonstrați-vă identitatea pentru a accesa istoricul mesajelor criptate."</string> + <string name="screen_session_verification_open_existing_session_title">"Deschideți o sesiune existentă"</string> + <string name="screen_session_verification_positive_button_canceled">"Reîncercați verificarea"</string> + <string name="screen_session_verification_positive_button_initial">"Sunt pregătit"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Se așteaptă confirmarea"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Comparăți emoticoalene asigurându-vă că apar în aceeași ordine."</string> + <string name="screen_session_verification_they_dont_match">"Nu se potrivesc"</string> + <string name="screen_session_verification_they_match">"Se potrivesc"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Acceptați solicitarea de a începe procesul de verificare în cealaltă sesiune pentru a continua."</string> + <string name="screen_session_verification_waiting_to_accept_title">"Se așteptă acceptarea cererii"</string> + <string name="screen_session_verification_cancelled_title">"Verificare anulată"</string> + <string name="screen_session_verification_positive_button_ready">"Începeți"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values-sk/translations.xml b/features/verifysession/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..275924e9ec --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Skontrolujte, či sa emotikony uvedené nižšie zhodujú s emotikonmi zobrazenými vo vašej druhej relácii."</string> + <string name="screen_session_verification_compare_emojis_title">"Porovnajte emotikony"</string> + <string name="screen_session_verification_complete_subtitle">"Vaša nová relácia je teraz overená. Má prístup k vašim zašifrovaným správam a ostatní používatelia ju budú vidieť ako dôveryhodnú."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Dokážte, že ste to vy, aby ste získali prístup k histórii vašich zašifrovaných správ."</string> + <string name="screen_session_verification_open_existing_session_title">"Otvoriť existujúcu reláciu"</string> + <string name="screen_session_verification_positive_button_canceled">"Zopakovať overenie"</string> + <string name="screen_session_verification_positive_button_initial">"Som pripravený/á"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Čaká sa na zhodu"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Porovnajte jedinečné emotikony a uistite sa, že sú zobrazené v rovnakom poradí."</string> + <string name="screen_session_verification_they_dont_match">"Nezhodujú sa"</string> + <string name="screen_session_verification_they_match">"Zhodujú sa"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Ak chcete pokračovať, prijmite žiadosť o spustenie procesu overenia vo vašej druhej relácii."</string> + <string name="screen_session_verification_waiting_to_accept_title">"Čaká sa na prijatie žiadosti"</string> + <string name="screen_session_verification_cancelled_title">"Overovanie zrušené"</string> + <string name="screen_session_verification_positive_button_ready">"Spustiť"</string> +</resources> diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..67dc975128 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="screen_session_verification_cancelled_subtitle">"Something doesn’t seem right. Either the request timed out or the request was denied."</string> + <string name="screen_session_verification_compare_emojis_subtitle">"Confirm that the emojis below match those shown on your other session."</string> + <string name="screen_session_verification_compare_emojis_title">"Compare emojis"</string> + <string name="screen_session_verification_complete_subtitle">"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted."</string> + <string name="screen_session_verification_open_existing_session_subtitle">"Prove it’s you in order to access your encrypted message history."</string> + <string name="screen_session_verification_open_existing_session_title">"Open an existing session"</string> + <string name="screen_session_verification_positive_button_canceled">"Retry verification"</string> + <string name="screen_session_verification_positive_button_initial">"I am ready"</string> + <string name="screen_session_verification_positive_button_verifying_ongoing">"Waiting to match"</string> + <string name="screen_session_verification_request_accepted_subtitle">"Compare the unique emoji, ensuring they appear in the same order."</string> + <string name="screen_session_verification_they_dont_match">"They don’t match"</string> + <string name="screen_session_verification_they_match">"They match"</string> + <string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string> + <string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string> + <string name="screen_session_verification_cancelled_title">"Verification cancelled"</string> + <string name="screen_session_verification_positive_button_ready">"Start"</string> +</resources> diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt new file mode 100644 index 0000000000..0b58c125de --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.features.verifysession.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class VerifySelfSessionPresenterTests { + + @Test + fun `present - Initial state is received`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial) + } + } + + @Test + fun `present - Handles requestVerification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + requestVerificationAndAwaitVerifyingState(service) + } + } + + @Test + fun `present - Handles startSasVerification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.StartSasVerification) + // Await for other device response: + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + // ChallengeReceived: + service.triggerReceiveVerificationData() + val verifyingState = awaitItem() + assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + } + } + + @Test + fun `present - Cancelation on initial state does nothing`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial) + val eventSink = initialState.eventSink + eventSink(VerifySelfSessionViewEvents.CancelAndClose) + expectNoEvents() + } + } + + @Test + fun `present - A fail in the flow cancels it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val state = requestVerificationAndAwaitVerifyingState(service) + service.shouldFail = true + state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification) + // Cancelling + assertThat(awaitItem().verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + // Cancelled + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + @Test + fun `present - Canceling the flow once it's verifying cancels it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(VerifySelfSessionViewEvents.CancelAndClose) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + @Test + fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + requestVerificationAndAwaitVerifyingState(service) + service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(emptyList())) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - Restart after cancelation returns to requesting verification`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val state = requestVerificationAndAwaitVerifyingState(service) + service.givenVerificationFlowState(VerificationFlowState.Canceled) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + state.eventSink(VerifySelfSessionViewEvents.Restart) + // Went back to requesting verification + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved, the flow completes if there is no error`() = runTest { + val emojis = listOf<VerificationEmoji>( + VerificationEmoji("😄", "Smile") + ) + val service = FakeSessionVerificationService().apply { + givenEmojiList(emojis) + } + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Loading())) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed) + } + } + + @Test + fun `present - When verification is declined, the flow is canceled`() = runTest { + val service = FakeSessionVerificationService() + val presenter = createPresenter(service) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Loading())) + assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled) + } + } + + private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState( + fakeService: FakeSessionVerificationService + ): VerifySelfSessionState { + var state = awaitItem() + assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Initial) + state.eventSink(VerifySelfSessionViewEvents.RequestVerification) + // Await for other device response: + state = awaitItem() + assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + // Await for the state to be Ready + state = awaitItem() + assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.Ready) + state.eventSink(VerifySelfSessionViewEvents.StartSasVerification) + // Await for other device response (again): + state = awaitItem() + assertThat(state.verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse) + fakeService.triggerReceiveVerificationData() + // Finally, ChallengeReceived: + state = awaitItem() + assertThat(state.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java) + return state + } + + private fun createPresenter(service: FakeSessionVerificationService = FakeSessionVerificationService()): VerifySelfSessionPresenter { + return VerifySelfSessionPresenter(service, VerifySelfSessionStateMachine(service)) + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..e15ee7a033 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,58 @@ +# +# Copyright (c) 2022 New Vector Ltd +# +# 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. +# + +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true + +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.parallel=true +# Check here for the reasons https://github.com/square/anvil/issues/693 +# useClasspathSnapshot=false is not enough in most cases. +kotlin.incremental=false + +# Dummy values for signing secrets / nightly +signing.element.nightly.storePassword=Secret +signing.element.nightly.keyId=Secret +signing.element.nightly.keyPassword=Secret + +# Customise the Lint version to use a more recent version than the one bundled with AGP +# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html +android.experimental.lint.version=8.2.0-alpha02 + +# Enable test fixture for all modules by default +android.experimental.enableTestFixtures=true + +# Create BuildConfig files as bytecode to avoid Java compilation phase +android.enableBuildConfigAsBytecode=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..f0759ee705 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,206 @@ +# This file is referenced in ./plugins/settings.gradle.kts to generate the version catalog. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +# Project +android_gradle_plugin = "8.0.2" +kotlin = "1.8.22" +ksp = "1.8.22-1.0.11" +molecule = "0.11.0" + +# AndroidX +material = "1.9.0" +core = "1.10.1" +datastore = "1.0.0" +constraintlayout = "2.1.4" +constraintlayout_compose = "1.0.1" +recyclerview = "1.3.0" +lifecycle = "2.6.1" +activity = "1.7.2" +startup = "1.1.1" +media3 = "1.1.0" +browser = "1.5.0" + +# Compose +compose_bom = "2023.06.01" +composecompiler = "1.4.8" + +# Coroutines +coroutines = "1.7.2" + +# Accompanist +accompanist = "0.30.1" + +# Test +test_core = "1.5.0" + +#other +coil = "2.4.0" +datetime = "0.4.0" +serialization_json = "1.5.1" +showkase = "1.0.0-beta18" +jsoup = "1.16.1" +appyx = "1.3.0" +dependencycheck = "8.3.1" +dependencyanalysis = "1.20.0" +stem = "2.3.0" +sqldelight = "1.5.5" +telephoto = "0.4.0" + +# DI +dagger = "2.47" +anvil = "2.4.6" + +# Auto service +autoservice = "1.1.1" + +# quality +detekt = "1.23.0" +dependencygraph = "0.12" + +[libraries] +# Project +android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } +# https://developer.android.com/studio/write/java8-support#library-desugaring-versions +android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3" +kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +# https://firebase.google.com/docs/android/setup#available-libraries +google_firebase_bom = "com.google.firebase:firebase-bom:32.2.0" + +# AndroidX +androidx_material = { module = "com.google.android.material:material", version.ref = "material" } +androidx_core = { module = "androidx.core:core", version.ref = "core" } +androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } +androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6" +androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } + +androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +androidx_browser = { module = "androidx.browser:browser", version.ref = "browser" } +androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +androidx_splash = "androidx.core:core-splashscreen:1.0.1" +androidx_security_crypto = "androidx.security:security-crypto:1.0.0" +androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } + +androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } +androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" } +androidx_preference = "androidx.preference:preference:1.2.0" + +androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } + +# Coroutines +coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +# Accompanist +accompanist_animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" } +accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } +accompanist_material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "accompanist" } +accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } +accompanist_placeholder = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" } +accompanist_flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" } + +# Libraries +squareup_seismic = "com.squareup:seismic:1.0.3" + +# network +network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:4.11.0" +network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } +network_okhttp = { module = "com.squareup.okhttp3:okhttp" } +network_retrofit = "com.squareup.retrofit2:retrofit:2.9.0" +network_retrofit_converter_serialization = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0" + +# Test +test_core = { module = "androidx.test:core", version.ref = "test_core" } +test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } +test_arch_core = "androidx.arch.core:core-testing:2.2.0" +test_junit = "junit:junit:4.13.2" +test_runner = "androidx.test:runner:1.5.2" +test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" +test_junitext = "androidx.test.ext:junit:1.1.5" +test_mockk = "io.mockk:mockk:1.13.5" +test_barista = "com.adevinta.android:barista:4.3.0" +test_hamcrest = "org.hamcrest:hamcrest:2.2" +test_orchestrator = "androidx.test:orchestrator:1.4.2" +test_turbine = "app.cash.turbine:turbine:1.0.0" +test_truth = "com.google.truth:truth:1.1.5" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.12" +test_robolectric = "org.robolectric:robolectric:4.10.3" +test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } + +# Others +coil = { module = "io.coil-kt:coil", version.ref = "coil" } +coil_compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } +datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } +showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } +showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } +timber = "com.jakewharton.timber:timber:5.0.1" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.34" +sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" +sqlite = "androidx.sqlite:sqlite:2.3.1" +unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" +otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" +vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" +vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" +telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } +statemachine = "com.freeletics.flowredux:compose:1.1.0" +maplibre = "org.maplibre.gl:android-sdk:10.2.0" +maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.0" +maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0" + +# Analytics +posthog = "com.posthog.android:posthog:2.0.3" +sentry_android = "io.sentry:sentry-android:6.26.0" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8" + +# Di +inject = "javax.inject:javax.inject:1" +dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } +dagger_compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } +anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref = "anvil" } +anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" } + +# Auto services +google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } +google_autoservice_annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } + + +# Miscellaneous +# Add unused dependency to androidx.compose.compiler:compiler to let Renovate create PR to change the +# value of `composecompiler` (which is used to set composeOptions.kotlinCompilerExtensionVersion. +# See https://github.com/renovatebot/renovate/issues/18354 +android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } + +[bundles] + +[plugins] +android_application = { id = "com.android.application", version.ref = "android_gradle_plugin" } +android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } +kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +anvil = { id = "com.squareup.anvil", version.ref = "anvil" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = "org.jlleitschuh.gradle.ktlint:11.5.0" +dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } +dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } +dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" } +paparazzi = "app.cash.paparazzi:1.2.0" +sonarqube = "org.sonarqube:4.2.1.3168" +kover = "org.jetbrains.kotlinx.kover:0.6.1" +sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..033e24c4cd Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..a6f7c3a890 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=7c3ad722e9b0ce8205b91560fd6ce8296ac3eadf065672242fd73c06b8eeb6ee +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000000..fcb6fca147 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/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/HEAD/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + 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 + + +# 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"' + +# 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 \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000..6689b85bee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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=. +@rem This is normally unused +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% equ 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% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libraries/androidutils/.gitignore b/libraries/androidutils/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/androidutils/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts new file mode 100644 index 0000000000..92e3c46126 --- /dev/null +++ b/libraries/androidutils/build.gradle.kts @@ -0,0 +1,43 @@ + +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.androidutils" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + implementation(projects.libraries.di) + + implementation(projects.libraries.core) + implementation(libs.dagger) + implementation(libs.timber) + implementation(libs.androidx.corektx) + implementation(libs.androidx.activity.activity) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.browser) +} diff --git a/libraries/androidutils/consumer-rules.pro b/libraries/androidutils/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8b1ccda517 --- /dev/null +++ b/libraries/androidutils/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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.VIBRATE" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> +</manifest> diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt new file mode 100644 index 0000000000..6f8aa76d03 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.bitmap + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.InputStream +import kotlin.math.min + +fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { + outputStream().use { out -> + bitmap.compress(format, quality, out) + out.flush() + } +} + +/** + * Reads the EXIF metadata from the [inputStream] and rotates the current [Bitmap] to match it. + * @return The resulting [Bitmap] or `null` if no metadata was found. + */ +fun Bitmap.rotateToMetadataOrientation(inputStream: InputStream): Result<Bitmap> = + runCatching { rotateToMetadataOrientation(this, ExifInterface(inputStream)) } + +/** + * Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio. + * @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0. + */ +fun Bitmap.resizeToMax(maxWidth: Int, maxHeight: Int): Bitmap { + // No need to resize + if (this.width == maxWidth && this.height == maxHeight) return this + + val aspectRatio = this.width.toFloat() / this.height.toFloat() + val useWidth = aspectRatio >= 1 + val calculatedMaxWidth = min(this.width, maxWidth) + val calculatedMinHeight = min(this.height, maxHeight) + val width = if (useWidth) calculatedMaxWidth else (calculatedMinHeight * aspectRatio).toInt() + val height = if (useWidth) (calculatedMaxWidth / aspectRatio).toInt() else calculatedMinHeight + return scale(width, height) +} + +/** + * Calculates and returns [BitmapFactory.Options.inSampleSize] given a pair of [desiredWidth] & [desiredHeight] + * and the previously read [BitmapFactory.Options.outWidth] & [BitmapFactory.Options.outHeight]. + */ +fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight: Int): Int { + var inSampleSize = 1 + + if (outWidth > desiredWidth || outHeight > desiredHeight) { + val halfHeight: Int = outHeight / 2 + val halfWidth: Int = outWidth / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= desiredHeight && halfWidth / inSampleSize >= desiredWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} + +private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInterface): Bitmap { + val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.preRotate(-90f) + matrix.preScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.preRotate(90f) + matrix.preScale(-1f, 1f) + } + else -> return bitmap + } + + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt new file mode 100644 index 0000000000..ec0d9662c7 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.browser + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsSession + +/** + * Open url in custom tab or, if not available, in the default browser. + * If several compatible browsers are installed, the user will be proposed to choose one. + * Ref: https://developer.chrome.com/multidevice/android/customtabs. + */ +fun Activity.openUrlInChromeCustomTab( + session: CustomTabsSession?, + darkTheme: Boolean, + url: String +) { + try { + CustomTabsIntent.Builder() + .setDefaultColorSchemeParams( + CustomTabColorSchemeParams.Builder() + // TODO .setToolbarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)) + // TODO .setNavigationBarColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)) + .build() + ) + .setColorScheme( + when (darkTheme) { + false -> CustomTabsIntent.COLOR_SCHEME_LIGHT + true -> CustomTabsIntent.COLOR_SCHEME_DARK + } + ) + // Note: setting close button icon does not work + // .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp)) + // .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out) + // .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out) + .apply { session?.let { setSession(it) } } + .build() + .launchUrl(this, Uri.parse(url)) + } catch (activityNotFoundException: ActivityNotFoundException) { + // TODO context.toast(R.string.error_no_external_application_found) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt new file mode 100644 index 0000000000..cecf47eb1b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.clipboard + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.core.content.getSystemService +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class AndroidClipboardHelper @Inject constructor( + @ApplicationContext private val context: Context, +) : ClipboardHelper { + + private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>()) + + override fun copyPlainText(text: String) { + clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt new file mode 100644 index 0000000000..39cb719d48 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.clipboard + +/** + * Wrapper class for handling clipboard operations so it can be used in JVM environments. + */ +interface ClipboardHelper { + fun copyPlainText(text: String) + +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt new file mode 100644 index 0000000000..03cd70c768 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.clipboard + +class FakeClipboardHelper : ClipboardHelper { + + var clipboardContents: Any? = null + + override fun copyPlainText(text: String) { + clipboardContents = text + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt new file mode 100644 index 0000000000..69761ccbc2 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.compat + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build + +fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo( + packageName, + PackageManager.ApplicationInfoFlags.of(flags.toLong()) + ) + else -> @Suppress("DEPRECATION") getApplicationInfo(packageName, flags) + } +} + +fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int): PackageInfo { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(flags.toLong()) + ) + else -> @Suppress("DEPRECATION") getPackageInfo(packageName, flags) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/extensions/isEmail.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/extensions/isEmail.kt new file mode 100644 index 0000000000..b47033286d --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/extensions/isEmail.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.extensions + +import android.util.Patterns + +/** + * Check if a CharSequence is an email. + */ +fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt new file mode 100644 index 0000000000..b730eec3d5 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.file + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.net.toFile + +fun Context.getMimeType(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> contentResolver.getType(uri) + else -> null +} + +fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) + ContentResolver.SCHEME_FILE -> uri.toFile().name + else -> null +} + +fun Context.getFileSize(uri: Uri): Long { + return when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> getContentFileSize(uri) + ContentResolver.SCHEME_FILE -> uri.toFile().length() + else -> 0 + } ?: 0 +} + +private fun Context.getContentFileSize(uri: Uri): Long? = runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + return@use cursor.getColumnIndexOrThrow(OpenableColumns.SIZE).let(cursor::getLong) + } +}.getOrNull() + +private fun Context.getContentFileName(uri: Uri): String? = runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) + } +}.getOrNull() diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt new file mode 100644 index 0000000000..815c5fad6e --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.file + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKeys +import java.io.File + +class EncryptedFileFactory( + private val context: Context, +) { + fun create(file: File): EncryptedFile { + // We need to use the same key for all the encrypted files. + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return EncryptedFile.Builder( + file, + context, + masterKeyAlias, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt new file mode 100644 index 0000000000..ea214ff683 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.file + +import android.content.Context +import androidx.annotation.WorkerThread +import io.element.android.libraries.core.data.tryOrNull +import timber.log.Timber +import java.io.File +import java.util.Locale +import java.util.UUID + +fun File.safeDelete() { + tryOrNull( + onError = { + Timber.e(it, "Error, unable to delete file $path") + }, + operation = { + if (delete().not()) { + Timber.w("Warning, unable to delete file $path") + } + } + ) +} + +fun File.safeRenameTo(dest: File) { + tryOrNull( + onError = { + Timber.e(it, "Error, unable to rename file $path to ${dest.path}") + }, + operation = { + if (renameTo(dest).not()) { + Timber.w("Warning, unable to rename file $path to ${dest.path}") + } + } + ) +} + +fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File { + val suffix = extension?.let { ".$extension" } + return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } +} + +// Implementation should return true in case of success +typealias ActionOnFile = (file: File) -> Boolean + +/* ========================================================================================== + * Log + * ========================================================================================== */ + +fun lsFiles(context: Context) { + Timber.v("Content of cache dir:") + recursiveActionOnFile(context.cacheDir, ::logAction) + + Timber.v("Content of files dir:") + recursiveActionOnFile(context.filesDir, ::logAction) +} + +private fun logAction(file: File): Boolean { + if (file.isDirectory) { + Timber.v(file.toString()) + } else { + Timber.v("$file ${file.length()} bytes") + } + return true +} + +/* ========================================================================================== + * Private + * ========================================================================================== */ + +/** + * Return true in case of success. + */ +private fun recursiveActionOnFile(file: File, action: ActionOnFile): Boolean { + if (file.isDirectory) { + file.list()?.forEach { + val result = recursiveActionOnFile(File(file, it), action) + + if (!result) { + // Break the loop + return false + } + } + } + + return action.invoke(file) +} + +/** + * Get the file extension of a fileUri or a filename. + * + * @param fileUri the fileUri (can be a simple filename) + * @return the file extension, in lower case, or null is extension is not available or empty + */ +fun getFileExtension(fileUri: String): String? { + var reducedStr = fileUri + + if (reducedStr.isNotEmpty()) { + // Remove fragment + reducedStr = reducedStr.substringBeforeLast('#') + + // Remove query + reducedStr = reducedStr.substringBeforeLast('?') + + // Remove path + val filename = reducedStr.substringAfterLast('/') + + // Contrary to method MimeTypeMap.getFileExtensionFromUrl, we do not check the pattern + // See https://stackoverflow.com/questions/14320527/android-should-i-use-mimetypemap-getfileextensionfromurl-bugs + if (filename.isNotEmpty()) { + val dotPos = filename.lastIndexOf('.') + if (0 <= dotPos) { + val ext = filename.substring(dotPos + 1) + + if (ext.isNotBlank()) { + return ext.lowercase(Locale.ROOT) + } + } + } + } + + return null +} + +/* ========================================================================================== + * Size + * ========================================================================================== */ + +@WorkerThread +fun File.getSizeOfFiles(): Long { + return walkTopDown() + .onEnter { + Timber.v("Get size of ${it.absolutePath}") + true + } + .sumOf { it.length() } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/compressFile.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/compressFile.kt new file mode 100644 index 0000000000..7e55e5e62d --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/compressFile.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.file + +import timber.log.Timber +import java.io.File +import java.util.zip.GZIPOutputStream + +/** + * GZip a file. + * + * @param file the input file + * @return the gzipped file + */ +fun compressFile(file: File): File? { + Timber.v("## compressFile() : compress ${file.name}") + + val dstFile = file.resolveSibling(file.name + ".gz") + + if (dstFile.exists()) { + dstFile.safeDelete() + } + + return try { + GZIPOutputStream(dstFile.outputStream()).use { gos -> + file.inputStream().use { + it.copyTo(gos, 2048) + } + } + + Timber.v("## compressFile() : ${file.length()} compressed to ${dstFile.length()} bytes") + dstFile + } catch (e: Exception) { + Timber.e(e, "## compressFile() failed") + null + } catch (oom: OutOfMemoryError) { + Timber.e(oom, "## compressFile() failed") + null + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt new file mode 100644 index 0000000000..9cd70febcc --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.filesize + +import android.content.Context +import android.os.Build +import android.text.format.Formatter +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidFileSizeFormatter @Inject constructor( + @ApplicationContext private val context: Context, + ) : FileSizeFormatter { + override fun format(fileSize: Long, useShortFormat: Boolean): String { + // Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes. + // We want to avoid that. + val normalizedSize = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) { + fileSize + } else { + // First convert the size + when { + fileSize < 1024 -> fileSize + fileSize < 1024 * 1024 -> fileSize * 1000 / 1024 + fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024 + else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024 + } + } + + return if (useShortFormat) { + Formatter.formatShortFileSize(context, normalizedSize) + } else { + Formatter.formatFileSize(context, normalizedSize) + } + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt new file mode 100644 index 0000000000..32c0239428 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.filesize + +class FakeFileSizeFormatter : FileSizeFormatter { + override fun format(fileSize: Long, useShortFormat: Boolean): String { + return "$fileSize Bytes" + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt new file mode 100644 index 0000000000..7be38bf9bd --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.filesize + +interface FileSizeFormatter { + /** + * Formats a content size to be in the form of bytes, kilobytes, megabytes, etc. + */ + fun format(fileSize: Long, useShortFormat: Boolean = true): String +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hardware/vibrator.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hardware/vibrator.kt new file mode 100644 index 0000000000..3e717ef6bf --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hardware/vibrator.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.hardware + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.core.content.getSystemService + +fun Context.vibrate(durationMillis: Long = 100) { + val vibrator = getSystemService<Vibrator>() ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(durationMillis) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt new file mode 100644 index 0000000000..8f942957e0 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.media + +import android.media.MediaMetadataRetriever + +/** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */ +inline fun <T> MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T { + return try { + block() + } finally { + release() + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt new file mode 100644 index 0000000000..85b17c6ff8 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/network/WifiDetector.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.core.content.getSystemService +import io.element.android.libraries.core.bool.orFalse +import timber.log.Timber + +class WifiDetector( + context: Context +) { + private val connectivityManager = context.getSystemService<ConnectivityManager>()!! + + fun isConnectedToWifi(): Boolean { + return connectivityManager.activeNetwork + ?.let { connectivityManager.getNetworkCapabilities(it) } + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + .orFalse() + .also { Timber.d("isConnected to WiFi: $it") } + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt new file mode 100644 index 0000000000..3eaa907303 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.system + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.core.content.getSystemService + +class CopyToClipboardUseCase( + private val context: Context, +) { + fun execute(text: CharSequence) { + context.getSystemService<ClipboardManager>() + ?.setPrimaryClip(ClipData.newPlainText("", text)) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt new file mode 100644 index 0000000000..65a5dc9e0d --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.system + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Activity +import android.app.NotificationManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat + +/** + * Tells if the application ignores battery optimizations. + * + * Ignoring them allows the app to run in background to make background sync with the homeserver. + * This user option appears on Android M but Android O enforces its usage and kills apps not + * authorised by the user to run in background. + * + * @return true if battery optimisations are ignored + */ +fun Context.isIgnoringBatteryOptimizations(): Boolean { + // no issue before Android M, battery optimisations did not exist + return getSystemService<PowerManager>()?.isIgnoringBatteryOptimizations(packageName) == true +} + +fun Context.isAirplaneModeOn(): Boolean { + return Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0 +} + +fun Context.isAnimationEnabled(): Boolean { + return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + +/** + * Return the application label of the provided package. If not found, the package is returned. + */ +fun Context.getApplicationLabel(packageName: String): String { + return try { + val ai = packageManager.getApplicationInfoCompat(packageName, 0) + packageManager.getApplicationLabel(ai).toString() + } catch (e: PackageManager.NameNotFoundException) { + packageName + } +} + +/** + * Return true it the user has enabled the do not disturb mode. + */ +fun Context.isDoNotDisturbModeOn(): Boolean { + // We cannot use NotificationManagerCompat here. + val setting = getSystemService<NotificationManager>()!!.currentInterruptionFilter + + return setting == NotificationManager.INTERRUPTION_FILTER_NONE || + setting == NotificationManager.INTERRUPTION_FILTER_ALARMS +} + +/** + * display the system dialog for granting this permission. If previously granted, the + * system will not show it (so you should call this method). + * + * Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed() + * will return false and the notification privacy will fallback to "LOW_DETAIL". + */ +@SuppressLint("BatteryLife") +fun Context.requestDisablingBatteryOptimization(activityResultLauncher: ActivityResultLauncher<Intent>) { + val intent = Intent() + intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + intent.data = Uri.parse("package:$packageName") + activityResultLauncher.launch(intent) +} + +// ============================================================================================================== +// Clipboard helper +// ============================================================================================================== + +/** + * Copy a text to the clipboard, and display a Toast when done. + * + * @receiver the context + * @param text the text to copy + * @param toastMessage content of the toast message as a String resource. Null for no toast + */ +fun Context.copyToClipboard( + text: CharSequence, + toastMessage: String? = null +) { + CopyToClipboardUseCase(this).execute(text) + toastMessage?.let { toast(it) } +} + +/** + * Shows notification settings for the current app. + * In android O will directly opens the notification settings, in lower version it will show the App settings + */ +fun Context.startNotificationSettingsIntent(activityResultLauncher: ActivityResultLauncher<Intent>) { + val intent = Intent() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + } else { + intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.data = Uri.fromParts("package", packageName, null) + } + activityResultLauncher.launch(intent) +} + +fun Context.openAppSettingsPage( + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + try { + startActivity( + Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.fromParts("package", packageName, null) + } + ) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +/** + * Shows notification system settings for the given channel id. + */ +@TargetApi(Build.VERSION_CODES.O) +fun Activity.startNotificationChannelSettingsIntent(channelID: String) { + if (!supportNotificationChannels()) return + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelID) + } + startActivity(intent) +} + +fun Context.startAddGoogleAccountIntent( + activityResultLauncher: ActivityResultLauncher<Intent>, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent(Settings.ACTION_ADD_ACCOUNT) + intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) + try { + activityResultLauncher.launch(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun Context.startInstallFromSourceIntent( + activityResultLauncher: ActivityResultLauncher<Intent>, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse(String.format("package:%s", packageName))) + try { + activityResultLauncher.launch(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +fun Context.startSharePlainTextIntent( + activityResultLauncher: ActivityResultLauncher<Intent>?, + chooserTitle: String?, + text: String, + subject: String? = null, + extraTitle: String? = null, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val share = Intent(Intent.ACTION_SEND) + share.type = "text/plain" + share.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) + // Add data to the intent, the receiving app will decide what to do with it. + share.putExtra(Intent.EXTRA_SUBJECT, subject) + share.putExtra(Intent.EXTRA_TEXT, text) + + extraTitle?.let { + share.putExtra(Intent.EXTRA_TITLE, it) + } + + val intent = Intent.createChooser(share, chooserTitle) + try { + if (activityResultLauncher != null) { + activityResultLauncher.launch(intent) + } else { + startActivity(intent) + } + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +fun Context.startImportTextFromFileIntent( + activityResultLauncher: ActivityResultLauncher<Intent>, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "text/plain" + } + try { + activityResultLauncher.launch(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +@Suppress("SwallowedException") +fun Context.openUrlInExternalApp( + url: String, + errorMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + try { + startActivity(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(errorMessage) + } +} + +// Not in KTX anymore +fun Context.toast(resId: Int) { + Toast.makeText(this, resId, Toast.LENGTH_SHORT).show() +} + +// Not in KTX anymore +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt new file mode 100644 index 0000000000..fba6066a64 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.throttler + +import android.os.SystemClock + +/** + * Simple ThrottleFirst + * See https://raw.githubusercontent.com/wiki/ReactiveX/RxJava/images/rx-operators/throttleFirst.png + */ +class FirstThrottler(private val minimumInterval: Long = 800) { + private var lastDate = 0L + + sealed class CanHandleResult { + object Yes : CanHandleResult() + data class No(val shouldWaitMillis: Long) : CanHandleResult() + + fun waitMillis(): Long { + return when (this) { + Yes -> 0 + is No -> shouldWaitMillis + } + } + } + + fun canHandle(): CanHandleResult { + val now = SystemClock.elapsedRealtime() + val delaySinceLast = now - lastDate + if (delaySinceLast > minimumInterval) { + lastDate = now + return CanHandleResult.Yes + } + + // Too early + return CanHandleResult.No(minimumInterval - delaySinceLast) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/DimensionConverter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/DimensionConverter.kt new file mode 100644 index 0000000000..beead850b3 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/DimensionConverter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.ui + +import android.content.res.Resources +import android.util.TypedValue +import androidx.annotation.Px + +class DimensionConverter(private val resources: Resources) { + + @Px + fun dpToPx(dp: Int): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + resources.displayMetrics + ).toInt() + } + + @Px + fun spToPx(sp: Int): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + sp.toFloat(), + resources.displayMetrics + ).toInt() + } + + fun pxToDp(@Px px: Int): Int { + return (px.toFloat() / resources.displayMetrics.density).toInt() + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt new file mode 100644 index 0000000000..0639f29d1f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.ui + +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.core.content.getSystemService + +fun View.hideKeyboard() { + val imm = context?.getSystemService<InputMethodManager>() + imm?.hideSoftInputFromWindow(windowToken, 0) +} + +fun View.showKeyboard(andRequestFocus: Boolean = false) { + if (andRequestFocus) { + requestFocus() + } + val imm = context?.getSystemService<InputMethodManager>() + imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) +} + +fun View.setHorizontalPadding(padding: Int) { + setPadding( + padding, + paddingTop, + padding, + paddingBottom + ) +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt new file mode 100644 index 0000000000..1375104b79 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.androidutils.uri + +import android.net.Uri + +const val ASSET_FILE_PATH_ROOT = "android_asset" +const val IGNORED_SCHEMA = "ignored" + +fun Uri.isIgnored() = scheme == IGNORED_SCHEMA + +fun createIgnoredUri(path: String): Uri = Uri.parse("$IGNORED_SCHEMA://$path") + +val Uri.firstPathSegment: String? + get() = pathSegments.firstOrNull() diff --git a/libraries/androidutils/src/main/res/values-cs/translations.xml b/libraries/androidutils/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..345812c6ff --- /dev/null +++ b/libraries/androidutils/src/main/res/values-cs/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-de/translations.xml b/libraries/androidutils/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..d30d83f831 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-de/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Keine kompatible App für diese Aktion gefunden."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-es/translations.xml b/libraries/androidutils/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..d95373265c --- /dev/null +++ b/libraries/androidutils/src/main/res/values-es/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"No se encontró ninguna aplicación compatible con esta acción."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-fr/translations.xml b/libraries/androidutils/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..b974766fce --- /dev/null +++ b/libraries/androidutils/src/main/res/values-fr/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Aucune application compatible n\'a été trouvée pour gérer cette action."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-it/translations.xml b/libraries/androidutils/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..fcafd9fe3f --- /dev/null +++ b/libraries/androidutils/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Non è stata trovata alcuna app compatibile per gestire questa azione."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-ldrtl/integers.xml b/libraries/androidutils/src/main/res/values-ldrtl/integers.xml new file mode 100644 index 0000000000..f563f32b51 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ldrtl/integers.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <integer name="rtl_x_multiplier">-1</integer> + <integer name="rtl_mirror_flip">180</integer> + +</resources> diff --git a/libraries/androidutils/src/main/res/values-ro/translations.xml b/libraries/androidutils/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..eac7dd0285 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ro/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values-sk/translations.xml b/libraries/androidutils/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..9ef19930a0 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-sk/translations.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"Nebola nájdená žiadna kompatibilná aplikácia, ktorá by túto akciu dokázala spracovať."</string> +</resources> diff --git a/libraries/androidutils/src/main/res/values/integers.xml b/libraries/androidutils/src/main/res/values/integers.xml new file mode 100644 index 0000000000..ecbfa4cdda --- /dev/null +++ b/libraries/androidutils/src/main/res/values/integers.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <integer name="rtl_x_multiplier">1</integer> + <integer name="rtl_mirror_flip">0</integer> + +</resources> diff --git a/libraries/androidutils/src/main/res/values/localazy.xml b/libraries/androidutils/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..741c1b20ec --- /dev/null +++ b/libraries/androidutils/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="error_no_compatible_app_found">"No compatible app was found to handle this action."</string> +</resources> diff --git a/libraries/architecture/.gitignore b/libraries/architecture/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/architecture/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/architecture/build.gradle.kts b/libraries/architecture/build.gradle.kts new file mode 100644 index 0000000000..68a25ead04 --- /dev/null +++ b/libraries/architecture/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.architecture" +} + +dependencies { + api(projects.libraries.di) + api(libs.dagger) + api(libs.appyx.core) + api(libs.androidx.lifecycle.runtime) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AssistedNodeFactory.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AssistedNodeFactory.kt new file mode 100644 index 0000000000..6852bf9adb --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AssistedNodeFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin + +interface AssistedNodeFactory<NODE : Node> { + fun create(buildContext: BuildContext, plugins: List<Plugin>): NODE +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt new file mode 100644 index 0000000000..fe728562e9 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Sealed type that allows to model an asynchronous operation. + */ +@Stable +sealed interface Async<out T> { + + /** + * Represents a failed operation. + * + * @param T the type of data returned by the operation. + * @property error the error that caused the operation to fail. + * @property prevData the data returned by a previous successful run of the operation if any. + */ + data class Failure<out T>( + val error: Throwable, + val prevData: T? = null, + ) : Async<T> + + /** + * Represents an operation that is currently ongoing. + * + * @param T the type of data returned by the operation. + * @property prevData the data returned by a previous successful run of the operation if any. + */ + data class Loading<out T>( + val prevData: T? = null, + ) : Async<T> + + /** + * Represents a successful operation. + * + * @param T the type of data returned by the operation. + * @property data the data returned by the operation. + */ + data class Success<out T>( + val data: T, + ) : Async<T> + + /** + * Represents an uninitialized operation (i.e. yet to be run). + */ + object Uninitialized : Async<Nothing> + + /** + * Returns the data returned by the operation, or null otherwise. + * + * Please note this method may return stale data if the operation is not [Success]. + */ + fun dataOrNull(): T? = when (this) { + is Failure -> prevData + is Loading -> prevData + is Success -> data + Uninitialized -> null + } + + /** + * Returns the error that caused the operation to fail, or null otherwise. + */ + fun errorOrNull(): Throwable? = when (this) { + is Failure -> error + else -> null + } + + fun isFailure(): Boolean = this is Failure<T> + + fun isLoading(): Boolean = this is Loading<T> + + fun isSuccess(): Boolean = this is Success<T> + + fun isUninitialized(): Boolean = this == Uninitialized +} + +suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + block: () -> T, +): Result<T> = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = { + runCatching { + block() + } + }, +) + +suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState( + state: MutableState<Async<T>>, + errorTransform: (Throwable) -> Throwable = { it }, +): Result<T> = runUpdatingState( + state = state, + errorTransform = errorTransform, + resultBlock = { + runCatching { + this() + } + }, +) + +suspend inline fun <T> MutableState<Async<T>>.runUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: () -> Result<T>, +): Result<T> = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = resultBlock, +) + +/** + * Calls the specified [Result]-returning function [resultBlock] + * encapsulating its progress and return value into an [Async] while + * posting its updates to the MutableState [state]. + * + * @param T the type of data returned by the operation. + * @param state the [MutableState] to post updates to. + * @param errorTransform a function to transform the error before posting it. + * @param resultBlock a suspending function that returns a [Result]. + * @return the [Result] returned by [resultBlock]. + */ +@OptIn(ExperimentalContracts::class) +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +suspend inline fun <T> runUpdatingState( + state: MutableState<Async<T>>, + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: suspend () -> Result<T>, +): Result<T> { + contract { + callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE) + } + val prevData = state.value.dataOrNull() + state.value = Async.Loading(prevData = prevData) + return resultBlock().fold( + onSuccess = { + state.value = Async.Success(it) + Result.success(it) + }, + onFailure = { + val error = errorTransform(it) + state.value = Async.Failure( + error = error, + prevData = prevData, + ) + Result.failure(error) + } + ) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BackstackNode.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BackstackNode.kt new file mode 100644 index 0000000000..ec22c5e21f --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BackstackNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.compose.runtime.Stable +import com.bumble.appyx.core.children.ChildEntry +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack + +/** + * This class is just an helper for configuring a backstack directly in the constructor. + * With this we can more easily use constructor injection without having a secondary constructor to create the [BackStack] instance. + * Can be used instead of [ParentNode] in flow nodes. + */ +@Stable +abstract class BackstackNode<NavTarget : Any>( + val backstack: BackStack<NavTarget>, + buildContext: BuildContext, + childKeepMode: ChildEntry.KeepMode = ChildEntry.KeepMode.KEEP, + plugins: List<Plugin> +) : ParentNode<NavTarget>( + navModel = backstack, + buildContext = buildContext, + plugins = plugins, + childKeepMode = childKeepMode, +) { + override fun onBuilt() { + super.onBuilt() + lifecycle.logLifecycle(this::class.java.simpleName) + whenChildAttached<Node> { _, child -> + // BackstackNode will be logged by their parent. + if (child !is BackstackNode<*>) { + child.lifecycle.logLifecycle(child::class.java.simpleName) + } + } + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt new file mode 100644 index 0000000000..e4a6d7ae7d --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import android.content.Context +import android.content.ContextWrapper +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.di.DaggerComponentOwner + +inline fun <reified T : Any> Node.bindings() = bindings(T::class.java) +inline fun <reified T : Any> Context.bindings() = bindings(T::class.java) + +fun <T : Any> Context.bindings(klass: Class<T>): T { + // search dagger components in the context hierarchy + return generateSequence(this) { (it as? ContextWrapper)?.baseContext } + .plus(applicationContext) + .filterIsInstance<DaggerComponentOwner>() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} + +fun <T : Any> Node.bindings(klass: Class<T>): T { + // search dagger components in node hierarchy + return generateSequence(this, Node::parent) + .filterIsInstance<DaggerComponentOwner>() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/FeatureEntryPoint.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/FeatureEntryPoint.kt new file mode 100644 index 0000000000..031bab7397 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/FeatureEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node + +/** + * This interface represents an entrypoint to a feature. Should be used to return the entrypoint node of the feature without exposing the internal types. + */ +interface FeatureEntryPoint + +/** + * Can be used when the feature only exposes a simple node without the need of plugins. + */ +interface SimpleFeatureEntryPoint : FeatureEntryPoint { + fun createNode(parentNode: Node, buildContext: BuildContext): Node +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt new file mode 100644 index 0000000000..7c42c4cfe3 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.lifecycle.Lifecycle +import com.bumble.appyx.core.lifecycle.subscribe +import timber.log.Timber + +fun Lifecycle.logLifecycle(name: String) { + subscribe( + onCreate = { Timber.tag("Lifecycle").d("onCreate $name") }, + onPause = { Timber.tag("Lifecycle").d("onPause $name") }, + onResume = { Timber.tag("Lifecycle").d("onResume $name") }, + onDestroy = { Timber.tag("Lifecycle").d("onDestroy $name") }, + ) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeFactories.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeFactories.kt new file mode 100644 index 0000000000..6073b45351 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeFactories.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import android.content.Context +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin + +inline fun <reified NODE : Node> Node.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE { + val bindings: NodeFactoriesBindings = bindings() + return bindings.createNode(context, plugins) +} + +inline fun <reified NODE : Node> Context.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE { + val bindings: NodeFactoriesBindings = bindings() + return bindings.createNode(context, plugins) +} + +inline fun <reified NODE : Node> NodeFactoriesBindings.createNode(context: BuildContext, plugins: List<Plugin> = emptyList()): NODE { + val nodeClass = NODE::class.java + val nodeFactoryMap = nodeFactories() + // Note to developers: If you got the error below, make sure to build again after + // clearing the cache (sometimes several times) to let Dagger generate the NodeFactory. + val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.name}.") + + @Suppress("UNCHECKED_CAST") + val castedNodeFactory = nodeFactory as? AssistedNodeFactory<NODE> + val node = castedNodeFactory?.create(context, plugins) + return node as NODE +} + +interface NodeFactoriesBindings { + fun nodeFactories(): Map<Class<out Node>, AssistedNodeFactory<*>> +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt new file mode 100644 index 0000000000..b96d9e166b --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins + +interface NodeInputs : Plugin + +inline fun <reified I : NodeInputs> Node.inputs(): I { + return plugins<I>().firstOrNull() ?: throw RuntimeException("Make sure to actually pass NodeInputs plugin to your node") +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeKey.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeKey.kt new file mode 100644 index 0000000000..b28d5a8145 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeKey.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import com.bumble.appyx.core.node.Node +import dagger.MapKey +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@MapKey +annotation class NodeKey(val value: KClass<out Node>) diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt new file mode 100644 index 0000000000..c03ff64f6c --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.children.nodeOrNull +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +fun <NavTarget : Any> ParentNode<NavTarget>.childNode(navTarget: NavTarget): Node? { + val childMap = children.value + val key = childMap.keys.find { it.navTarget == navTarget } + return childMap[key]?.nodeOrNull +} + +suspend inline fun <reified N : Node, NavTarget : Any> ParentNode<NavTarget>.waitForChildAttached(crossinline predicate: (NavTarget) -> Boolean): N = + suspendCancellableCoroutine { continuation -> + lifecycleScope.launch { + children.collect { childMap -> + val expectedChildNode = childMap.entries + .map { it.key.navTarget } + .lastOrNull(predicate) + ?.let { + childNode(it) as? N + } + if (expectedChildNode != null && !continuation.isCompleted) { + continuation.resume(expectedChildNode) + } + } + }.invokeOnCompletion { + continuation.cancel() + } + } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Presenter.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Presenter.kt new file mode 100644 index 0000000000..9bfd089c27 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Presenter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.compose.runtime.Composable + +interface Presenter<State> { + @Composable + fun present(): State +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/animation/ScreenTransition.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/animation/ScreenTransition.kt new file mode 100644 index 0000000000..faac896b85 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/animation/ScreenTransition.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture.animation + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider + +@Composable +fun <NavTarget> rememberDefaultTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> { + return rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) +} diff --git a/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncKtTest.kt b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncKtTest.kt new file mode 100644 index 0000000000..4b6c75a108 --- /dev/null +++ b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncKtTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AsyncKtTest { + @Test + fun `updates state when block returns success`() = runTest { + val state = TestableMutableState<Async<Int>>(Async.Uninitialized) + + val result = runUpdatingState(state) { + delay(1) + Result.success(1) + } + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(1) + + assertThat(state.popFirst()).isEqualTo(Async.Uninitialized) + assertThat(state.popFirst()).isEqualTo(Async.Loading(null)) + assertThat(state.popFirst()).isEqualTo(Async.Success(1)) + state.assertNoMoreValues() + } + + @Test + fun `updates state when block returns failure`() = runTest { + val state = TestableMutableState<Async<Int>>(Async.Uninitialized) + + val result = runUpdatingState(state) { + delay(1) + Result.failure(MyThrowable("hello")) + } + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello")) + + assertThat(state.popFirst()).isEqualTo(Async.Uninitialized) + assertThat(state.popFirst()).isEqualTo(Async.Loading(null)) + assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("hello"))) + state.assertNoMoreValues() + } + + @Test + fun `updates state when block returns failure transforming the error`() = runTest { + val state = TestableMutableState<Async<Int>>(Async.Uninitialized) + + val result = runUpdatingState(state, { MyThrowable(it.message + " world") }) { + delay(1) + Result.failure(MyThrowable("hello")) + } + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello world")) + + assertThat(state.popFirst()).isEqualTo(Async.Uninitialized) + assertThat(state.popFirst()).isEqualTo(Async.Loading(null)) + assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("hello world"))) + state.assertNoMoreValues() + } +} + +/** + * A fake [MutableState] that allows to record all the states that were set. + */ +private class TestableMutableState<T>( + value: T +) : MutableState<T> { + + private val _deque = ArrayDeque<T>(listOf(value)) + + override var value: T + get() = _deque.last() + set(value) { + _deque.addLast(value) + } + + /** + * Returns the states that were set in the order they were set. + */ + fun popFirst(): T = _deque.removeFirst() + + fun assertNoMoreValues() { + assertThat(_deque).isEmpty() + } + + override operator fun component1(): T = value + + override operator fun component2(): (T) -> Unit = { value = it } +} + +/** + * An exception that is also a data class so we can compare it using equals. + */ +private data class MyThrowable(val myMessage: String) : Throwable(myMessage) diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts new file mode 100644 index 0000000000..40eaddfc06 --- /dev/null +++ b/libraries/core/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ +plugins { + id("java-library") + id("com.android.lint") + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencies { + implementation(libs.coroutines.core) + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/bool/Booleans.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/bool/Booleans.kt new file mode 100644 index 0000000000..2613176643 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/bool/Booleans.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.core.bool + +fun Boolean?.orTrue() = this ?: true + +fun Boolean?.orFalse() = this ?: false diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt new file mode 100644 index 0000000000..f5305f006b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.cache + +/** + * A FIFO circular buffer of T. + * This class is not thread safe. + */ +class CircularCache<T : Any>(cacheSize: Int, factory: (Int) -> Array<T?>) { + + companion object { + inline fun <reified T : Any> create(cacheSize: Int) = CircularCache(cacheSize) { Array<T?>(cacheSize) { null } } + } + + private val cache = factory(cacheSize) + private var writeIndex = 0 + + fun contains(value: T): Boolean = cache.contains(value) + + fun put(value: T) { + if (writeIndex == cache.size) { + writeIndex = 0 + } + cache[writeIndex] = value + writeIndex++ + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt new file mode 100644 index 0000000000..872ec0a5cd --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.plus + +/** + * Create a child scope of the current scope. + * The child scope will be cancelled if the parent scope is cancelled. + * The child scope will be cancelled if an exception is thrown in the parent scope. + * The parent scope won't be cancelled when an exception is thrown in the child scope. + * + * @param dispatcher the dispatcher to use for this scope. + * @param name the name of the coroutine. + */ +fun CoroutineScope.childScope( + dispatcher: CoroutineDispatcher, + name: String, +): CoroutineScope = run { + val supervisorJob = SupervisorJob(parent = coroutineContext.job) + this + dispatcher + supervisorJob + CoroutineName(name) +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt new file mode 100644 index 0000000000..918b6cda8a --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.CoroutineDispatcher + +data class CoroutineDispatchers( + val io: CoroutineDispatcher, + val computation: CoroutineDispatcher, + val main: CoroutineDispatcher, +) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt new file mode 100644 index 0000000000..302978066c --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.flow.flow + +/** Create a Flow emitting a single error event. It should be useful for tests. */ +fun <T> errorFlow(throwable: Throwable) = flow<T> { throw throwable } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ParallelMap.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ParallelMap.kt new file mode 100644 index 0000000000..178a5eef3a --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ParallelMap.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +// https://jivimberg.io/blog/2018/05/04/parallel-map-in-kotlin/ +suspend fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> = coroutineScope { + map { async { f(it) } }.awaitAll() +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt new file mode 100644 index 0000000000..b91d249547 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.core.data + +inline fun <A> tryOrNull(noinline onError: ((Throwable) -> Unit)? = null, operation: () -> A): A? { + return try { + operation() + } catch (any: Throwable) { + onError?.invoke(any) + null + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt new file mode 100644 index 0000000000..db07432df0 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.core.extensions + +fun Boolean.toOnOff() = if (this) "ON" else "OFF" +fun Boolean.to01() = if (this) "1" else "0" + +inline fun <T> T.ooi(block: (T) -> Unit): T = also(block) + +/** + * Return empty CharSequence if the CharSequence is null. + */ +fun CharSequence?.orEmpty() = this ?: "" + +/** + * Check if a CharSequence is a phone number. + */ +/* +fun CharSequence.isMsisdn(): Boolean { + return try { + PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null) + true + } catch (e: NumberParseException) { + false + } +} + */ + +/** + * Useful to append a String at the end of a filename but before the extension if any + * Ex: + * - "file.txt".insertBeforeLast("_foo") will return "file_foo.txt" + * - "file".insertBeforeLast("_foo") will return "file_foo" + * - "fi.le.txt".insertBeforeLast("_foo") will return "fi.le_foo.txt" + * - null.insertBeforeLast("_foo") will return "_foo". + */ +fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String { + if (this == null) return insert + val idx = lastIndexOf(delimiter) + return if (idx == -1) { + this + insert + } else { + replaceRange(idx, idx, insert) + } +} + +/** + * Truncate and ellipsize text if it exceeds the given length. + * + * Throws if length is < 1. + */ +fun String.ellipsize(length: Int): String { + require(length >= 1) + + if (this.length <= length) { + return this + } + + return "${this.take(length)}…" +} + +inline fun <reified R> Any?.takeAs(): R? { + return takeIf { it is R } as R? +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt new file mode 100644 index 0000000000..f7d96aebf5 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.extensions + +/** + * Can be used to transform some Throwable into some other. + */ +inline fun <R, T : R> Result<T>.mapFailure(transform: (exception: Throwable) -> Throwable): Result<R> { + return when (val exception = exceptionOrNull()) { + null -> this + else -> Result.failure(transform(exception)) + } +} + +/** + * Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result]. + * @return The result of the transform as a [Result]. + */ +inline fun <R, T> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> { + return map(transform).fold( + onSuccess = { it }, + onFailure = { Result.failure(it) } + ) +} + +/** + * Can be used to apply a [transform] that returns a [Result] to a base [Result] and get another [Result], catching any exception. + * @return The result of the transform or a caught exception wrapped in a [Result]. + */ +inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result<R> { + return mapCatching(transform).fold( + onSuccess = { it }, + onFailure = { Result.failure(it) } + ) +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt new file mode 100644 index 0000000000..2c9add5e8d --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.log.logger + +/** + * Parent class for custom logger tags. Can be used with Timber : + * + * val loggerTag = LoggerTag("MyTag", LoggerTag.VOIP) + * Timber.tag(loggerTag.value).v("My log message") + */ +open class LoggerTag(name: String, parentTag: LoggerTag? = null) { + + object SYNC : LoggerTag("SYNC") + object VOIP : LoggerTag("VOIP") + object CRYPTO : LoggerTag("CRYPTO") + object RENDEZVOUS : LoggerTag("RZ") + + val value: String = if (parentTag == null) { + name + } else { + "${parentTag.value}/$name" + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt new file mode 100644 index 0000000000..aaf54cc0f8 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.core.meta + +data class BuildMeta( + val buildType: BuildType, + val isDebuggable: Boolean, + val applicationName: String, + val applicationId: String, + val lowPrivacyLoggingEnabled: Boolean, + val versionName: String, + val versionCode: Int, + val gitRevision: String, + val gitRevisionDate: String, + val gitBranchName: String, + val flavorDescription: String, + val flavorShortDescription: String, +) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildType.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildType.kt new file mode 100644 index 0000000000..085fea8e0f --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildType.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.meta + +enum class BuildType { + RELEASE, + NIGHTLY, + DEBUG +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt new file mode 100644 index 0000000000..c6373fbbf6 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.mimetype + +import io.element.android.libraries.core.bool.orFalse + +// The Android SDK does not provide constant for mime type, add some of them here +object MimeTypes { + const val Any: String = "*/*" + const val OctetStream = "application/octet-stream" + const val Apk = "application/vnd.android.package-archive" + const val Pdf = "application/pdf" + + const val Images = "image/*" + + const val Png = "image/png" + const val BadJpg = "image/jpg" + const val Jpeg = "image/jpeg" + const val Gif = "image/gif" + + const val Videos = "video/*" + const val Mp4 = "video/mp4" + + const val Audio = "audio/*" + + const val Ogg = "audio/ogg" + const val Mp3 = "audio/mp3" + + const val PlainText = "text/plain" + + fun String?.normalizeMimeType() = if (this == BadJpg) Jpeg else this + + fun String?.isMimeTypeImage() = this?.startsWith("image/").orFalse() + fun String?.isMimeTypeVideo() = this?.startsWith("video/").orFalse() + fun String?.isMimeTypeAudio() = this?.startsWith("audio/").orFalse() + fun String?.isMimeTypeApplication() = this?.startsWith("application/").orFalse() + fun String?.isMimeTypeFile() = this?.startsWith("file/").orFalse() + fun String?.isMimeTypeText() = this?.startsWith("text/").orFalse() + fun String?.isMimeTypeAny() = this?.startsWith("*/").orFalse() +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt new file mode 100644 index 0000000000..4fb5e986fd --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.core.uri + +import java.net.URL + +fun String.isValidUrl(): Boolean { + return try { + URL(this) + true + } catch (t: Throwable) { + false + } +} + +/** + * Ensure string starts with "http". If it is not the case, "https://" is added, only if the String is not empty + */ +fun String.ensureProtocol(): String { + return when { + isEmpty() -> this + !startsWith("http") -> "https://$this" + else -> this + } +} + +fun String.ensureTrailingSlash(): String { + return when { + isEmpty() -> this + !endsWith("/") -> "$this/" + else -> this + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/cache/CircularCacheTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/cache/CircularCacheTest.kt new file mode 100644 index 0000000000..574eec9e3a --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/cache/CircularCacheTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.cache + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class CircularCacheTest { + @Test + fun `when putting more than cache size then cache is limited to cache size`() { + val (cache, internalData) = createIntCache(cacheSize = 3) + + cache.putInOrder(1, 1, 1, 1, 1, 1) + + assertThat(internalData).isEqualTo(arrayOf(1, 1, 1)) + } + + @Test + fun `when putting more than cache then acts as FIFO`() { + val (cache, internalData) = createIntCache(cacheSize = 3) + + cache.putInOrder(1, 2, 3, 4) + + assertThat(internalData).isEqualTo(arrayOf(4, 2, 3)) + } + + @Test + fun `given empty cache when checking if contains key then is false`() { + val (cache, _) = createIntCache(cacheSize = 3) + + val result = cache.contains(1) + + assertThat(result).isFalse() + } + + @Test + fun `given cached key when checking if contains key then is true`() { + val (cache, _) = createIntCache(cacheSize = 3) + + cache.put(1) + val result = cache.contains(1) + + assertThat(result).isTrue() + } + + private fun createIntCache(cacheSize: Int): Pair<CircularCache<Int>, Array<Int?>> { + var internalData: Array<Int?>? = null + val factory: (Int) -> Array<Int?> = { + Array<Int?>(it) { null }.also { array -> internalData = array } + } + return CircularCache(cacheSize, factory) to internalData!! + } + + private fun CircularCache<Int>.putInOrder(vararg values: Int) { + values.forEach { put(it) } + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt new file mode 100644 index 0000000000..5a3baa1297 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.extensions + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BasicExtensionsTest { + + @Test(expected = IllegalArgumentException::class) + fun `test ellipsize at 0`() { + "1234567890".ellipsize(0) + } + + @Test + fun `test ellipsize at 1`() { + assertEquals( + "1…", + "1234567890".ellipsize(1) + ) + } + + @Test + fun `test ellipsize at 5`() { + val output = "1234567890".ellipsize(5) + assertEquals("12345…", output) + } + + @Test + fun `test ellipsize noop 1`() { + val input = "12345" + val output = input.ellipsize(5) + assertEquals(input, output) + } + + @Test + fun `test ellipsize noop 2`() { + val input = "123" + val output = input.ellipsize(5) + assertEquals(input, output) + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt new file mode 100644 index 0000000000..de5703b090 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTests.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.core.extensions + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ResultTests { + + @Test + fun testFlatMap() { + val initial = Result.success("initial") + val otherResult = initial.flatMap { Result.success("other") } + val errorResult = initial.flatMap { Result.failure<String>(IllegalStateException("error")) } + + assertThat(otherResult.getOrNull()).isEqualTo("other") + assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error") + try { + initial.flatMap<String, String> { error("caught error") } + } catch (e: IllegalStateException) { + assertThat(e.message).isEqualTo("caught error") + } + + val initialError = Result.failure<String>(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMap { Result.success("other") } + val mapErrorToError = initialError.flatMap { Result.failure<String>(IllegalStateException("error")) } + val mapErrorAndCatch: Result<String> = initialError.flatMap { error("error") } + + assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error") + assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error") + assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error") + } + + @Test + fun testFlatMapCatching() { + val initial = Result.success("initial") + val otherResult = initial.flatMapCatching { Result.success("other") } + val errorResult = initial.flatMapCatching { Result.failure<String>(IllegalStateException("error")) } + val caughtExceptionResult: Result<String> = initial.flatMapCatching { error("caught error") } + + assertThat(otherResult.getOrNull()).isEqualTo("other") + assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error") + assertThat(caughtExceptionResult.exceptionOrNull()?.message).isEqualTo("caught error") + + val initialError = Result.failure<String>(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") } + val mapErrorToError = initialError.flatMapCatching { Result.failure<String>(IllegalStateException("error")) } + val mapErrorAndCatch: Result<String> = initialError.flatMapCatching { error("error") } + + assertThat(mapErrorToSuccess.exceptionOrNull()?.message).isEqualTo("initial error") + assertThat(mapErrorToError.exceptionOrNull()?.message).isEqualTo("initial error") + assertThat(mapErrorAndCatch.exceptionOrNull()?.message).isEqualTo("initial error") + } +} diff --git a/libraries/coroutines/.gitignore b/libraries/coroutines/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/coroutines/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/coroutines/build.gradle.kts b/libraries/coroutines/build.gradle.kts new file mode 100644 index 0000000000..f9a585ec95 --- /dev/null +++ b/libraries/coroutines/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/libraries/dateformatter/api/.gitignore b/libraries/dateformatter/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/dateformatter/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/dateformatter/api/build.gradle.kts b/libraries/dateformatter/api/build.gradle.kts new file mode 100644 index 0000000000..82f271919a --- /dev/null +++ b/libraries/dateformatter/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.dateformatter.api" +} diff --git a/libraries/dateformatter/api/consumer-rules.pro b/libraries/dateformatter/api/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libraries/dateformatter/api/src/main/AndroidManifest.xml b/libraries/dateformatter/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cf0e6386de --- /dev/null +++ b/libraries/dateformatter/api/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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 /> diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DaySeparatorFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DaySeparatorFormatter.kt new file mode 100644 index 0000000000..682f0c5745 --- /dev/null +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DaySeparatorFormatter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.api + +interface DaySeparatorFormatter { + fun format(timestamp: Long): String +} diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt new file mode 100644 index 0000000000..00a0e6b2bd --- /dev/null +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/LastMessageTimestampFormatter.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.api + +interface LastMessageTimestampFormatter { + fun format(timestamp: Long?): String +} diff --git a/libraries/dateformatter/impl/.gitignore b/libraries/dateformatter/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/dateformatter/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/dateformatter/impl/build.gradle.kts b/libraries/dateformatter/impl/build.gradle.kts new file mode 100644 index 0000000000..a980c835e1 --- /dev/null +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.libraries.dateformatter.impl" + + dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.di) + implementation(projects.anvilannotations) + + api(projects.libraries.dateformatter.api) + api(libs.datetime) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.dateformatter.test) + } +} diff --git a/libraries/dateformatter/impl/consumer-rules.pro b/libraries/dateformatter/impl/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libraries/dateformatter/impl/src/main/AndroidManifest.xml b/libraries/dateformatter/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cf0e6386de --- /dev/null +++ b/libraries/dateformatter/impl/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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 /> diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt new file mode 100644 index 0000000000..31b58085ba --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl + +import android.text.format.DateFormat +import android.text.format.DateUtils +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalDateTime +import java.time.Period +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlin.math.absoluteValue + +// TODO rework this date formatting +class DateFormatters @Inject constructor( + private val locale: Locale, + private val clock: Clock, + private val timeZone: TimeZone, +) { + + private val onlyTimeFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm" + DateTimeFormatter.ofPattern(pattern, locale) + } + + private val dateWithMonthFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM" + DateTimeFormatter.ofPattern(pattern, locale) + } + + private val dateWithYearFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy" + DateTimeFormatter.ofPattern(pattern, locale) + } + + internal fun formatTime(localDateTime: LocalDateTime): String { + return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithMonth(localDateTime: LocalDateTime): String { + return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithYear(localDateTime: LocalDateTime): String { + return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDate( + dateToFormat: LocalDateTime, + currentDate: LocalDateTime, + useRelative: Boolean + ): String { + val period = Period.between(dateToFormat.date.toJavaLocalDate(), currentDate.date.toJavaLocalDate()) + return if (period.years.absoluteValue >= 1) { + formatDateWithYear(dateToFormat) + } else if (useRelative && period.days.absoluteValue < 2 && period.months.absoluteValue < 1) { + getRelativeDay(dateToFormat.toInstant(timeZone).toEpochMilliseconds()) + } else { + formatDateWithMonth(dateToFormat) + } + } + + private fun getRelativeDay(ts: Long): String { + return DateUtils.getRelativeTimeSpanString( + ts, + clock.now().toEpochMilliseconds(), + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_WEEKDAY + )?.toString() ?: "" + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDaySeparatorFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDaySeparatorFormatter.kt new file mode 100644 index 0000000000..4c6ebbbde0 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDaySeparatorFormatter.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultDaySeparatorFormatter @Inject constructor( + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) : DaySeparatorFormatter { + + override fun format(timestamp: Long): String { + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + return dateFormatters.formatDateWithYear(dateToFormat) + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt new file mode 100644 index 0000000000..78294870d2 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatter.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultLastMessageTimestampFormatter @Inject constructor( + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) : LastMessageTimestampFormatter { + + override fun format(timestamp: Long?): String { + if (timestamp == null) return "" + val currentDate = localDateTimeProvider.providesNow() + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + val isSameDay = currentDate.date == dateToFormat.date + return when { + isSameDay -> { + dateFormatters.formatTime(dateToFormat) + } + else -> { + dateFormatters.formatDate( + dateToFormat = dateToFormat, + currentDate = currentDate, + useRelative = true + ) + } + } + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocalDateTimeProvider.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocalDateTimeProvider.kt new file mode 100644 index 0000000000..8395cb476c --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocalDateTimeProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import javax.inject.Inject + +class LocalDateTimeProvider @Inject constructor( + private val clock: Clock, + private val timezone: TimeZone, +) { + + fun providesNow(): LocalDateTime { + val now: Instant = clock.now() + return now.toLocalDateTime(timezone) + } + + fun providesFromTimestamp(timestamp: Long): LocalDateTime { + val tsInstant = Instant.fromEpochMilliseconds(timestamp) + return tsInstant.toLocalDateTime(timezone) + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/di/DateFormatterModule.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/di/DateFormatterModule.kt new file mode 100644 index 0000000000..352306da65 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/di/DateFormatterModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import java.util.* + +@Module +@ContributesTo(AppScope::class) +object DateFormatterModule { + @Provides + fun providesClock(): Clock = Clock.System + + @Provides + fun providesLocale(): Locale = Locale.getDefault() + + @Provides + fun providesTimezone(): TimeZone = TimeZone.currentSystemDefault() +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt new file mode 100644 index 0000000000..483e27af71 --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter +import io.element.android.libraries.dateformatter.test.FakeClock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import org.junit.Test +import java.util.Locale + +class DefaultLastMessageTimestampFormatterTest { + + @Test + fun `test null`() { + val now = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(null)).isEmpty() + } + + @Test + fun `test epoch`() { + val now = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(0)).isEqualTo("01.01.1970") + } + + @Test + fun `test now`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35") + } + + @Test + fun `test one second before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:23.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35") + } + + @Test + fun `test one minute before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:34:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:34") + } + + @Test + fun `test one hour before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T17:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("17:35") + } + + @Test + fun `test one day before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-05T18:35:24.00Z" + val formatter = createFormatter(now) + // TODO DateUtils.getRelativeTimeSpanString returns null. + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("") + } + + @Test + fun `test one month before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-03-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6 Mar") + } + + @Test + fun `test one year before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1979-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979") + } + + /** + * Create DefaultLastMessageFormatter and set current time to the provided date. + */ + private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageTimestampFormatter { + val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) } + val localDateTimeProvider = LocalDateTimeProvider(clock, TimeZone.UTC) + val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC) + return DefaultLastMessageTimestampFormatter(localDateTimeProvider, dateFormatters) + } +} diff --git a/libraries/dateformatter/test/.gitignore b/libraries/dateformatter/test/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/dateformatter/test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/dateformatter/test/build.gradle.kts b/libraries/dateformatter/test/build.gradle.kts new file mode 100644 index 0000000000..76bfc7c30c --- /dev/null +++ b/libraries/dateformatter/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.dateformatter.test" + + dependencies { + api(projects.libraries.dateformatter.api) + api(libs.datetime) + } +} diff --git a/libraries/dateformatter/test/consumer-rules.pro b/libraries/dateformatter/test/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libraries/dateformatter/test/src/main/AndroidManifest.xml b/libraries/dateformatter/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cf0e6386de --- /dev/null +++ b/libraries/dateformatter/test/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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 /> diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeClock.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeClock.kt new file mode 100644 index 0000000000..4716b0f5f0 --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeClock.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.test + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class FakeClock : Clock { + private var instant: Instant = Instant.fromEpochMilliseconds(0) + + fun givenInstant(instant: Instant) { + this.instant = instant + } + + override fun now(): Instant = instant +} diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDaySeparatorFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDaySeparatorFormatter.kt new file mode 100644 index 0000000000..202e3ee5ae --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDaySeparatorFormatter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.test + +import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter + +class FakeDaySeparatorFormatter : DaySeparatorFormatter { + + private var format = "" + + fun givenFormat(format: String) { + this.format = format + } + + override fun format(timestamp: Long): String { + return format + } +} diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt new file mode 100644 index 0000000000..47226c34d9 --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeLastMessageTimestampFormatter.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.dateformatter.test + +import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter + +class FakeLastMessageTimestampFormatter : LastMessageTimestampFormatter { + private var format = "" + fun givenFormat(format: String) { + this.format = format + } + + override fun format(timestamp: Long?): String { + return format + } +} diff --git a/libraries/deeplink/build.gradle.kts b/libraries/deeplink/build.gradle.kts new file mode 100644 index 0000000000..2ee61eb509 --- /dev/null +++ b/libraries/deeplink/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.deeplink" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.libraries.di) + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt new file mode 100644 index 0000000000..d16d31fb82 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +internal const val SCHEME = "elementx" +internal const val HOST = "open" + +object DeepLinkPaths { + const val INVITE_LIST = "invites" +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt new file mode 100644 index 0000000000..0cf2a7fca8 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import javax.inject.Inject + +class DeepLinkCreator @Inject constructor() { + fun room(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String { + return buildString { + append("$SCHEME://$HOST/") + append(sessionId.value) + if (roomId != null) { + append("/") + append(roomId.value) + if (threadId != null) { + append("/") + append(threadId.value) + } + } + } + } + + fun inviteList(sessionId: SessionId): String { + return buildString { + append("$SCHEME://$HOST/") + append(sessionId.value) + append("/") + append(DeepLinkPaths.INVITE_LIST) + } + } +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt new file mode 100644 index 0000000000..aa373411c7 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId + +sealed interface DeeplinkData { + /** Session id is common for all deep links. */ + val sessionId: SessionId + + /** The target is the root of the app, with the given [sessionId]. */ + data class Root(override val sessionId: SessionId) : DeeplinkData + + /** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */ + data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData + + /** The target is the invites list, with the given [sessionId]. */ + data class InviteList(override val sessionId: SessionId) : DeeplinkData +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt new file mode 100644 index 0000000000..7a5f9d5772 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +import android.content.Intent +import android.net.Uri +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import javax.inject.Inject + +class DeeplinkParser @Inject constructor() { + fun getFromIntent(intent: Intent): DeeplinkData? { + return intent + .takeIf { it.action == Intent.ACTION_VIEW } + ?.data + ?.toDeeplinkData() + } + + private fun Uri.toDeeplinkData(): DeeplinkData? { + if (scheme != SCHEME) return null + if (host != HOST) return null + val pathBits = path.orEmpty().split("/").drop(1) + val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null + + return when (val screenPathComponent = pathBits.elementAtOrNull(1)) { + null -> DeeplinkData.Root(sessionId) + DeepLinkPaths.INVITE_LIST -> DeeplinkData.InviteList(sessionId) + else -> { + val roomId = screenPathComponent.let(::RoomId) + val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId) + DeeplinkData.Room(sessionId, roomId, threadId) + } + } + } +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt new file mode 100644 index 0000000000..8ee94c31fe --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/usecase/InviteFriendsUseCase.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink.usecase + +import android.app.Activity +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject +import io.element.android.libraries.androidutils.R as AndroidUtilsR +import io.element.android.libraries.ui.strings.CommonStrings + +class InviteFriendsUseCase @Inject constructor( + private val stringProvider: StringProvider, + private val matrixClient: MatrixClient, + private val buildMeta: BuildMeta, +) { + fun execute(activity: Activity) { + val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId) + permalinkResult.fold( + onSuccess = { permalink -> + val appName = buildMeta.applicationName + activity.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = stringProvider.getString(CommonStrings.action_invite_friends), + text = stringProvider.getString(CommonStrings.invite_friends_text, appName, permalink), + extraTitle = stringProvider.getString(CommonStrings.invite_friends_rich_title, appName), + noActivityFoundMessage = stringProvider.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }, + onFailure = { + Timber.e(it) + } + ) + } +} diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt new file mode 100644 index 0000000000..70c047f8ed --- /dev/null +++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import org.junit.Test + +class DeepLinkCreatorTest { + + @Test + fun room() { + val sut = DeepLinkCreator() + assertThat(sut.room(A_SESSION_ID, null, null)) + .isEqualTo("elementx://open/@alice:server.org") + assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, null)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") + assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") + } + + @Test + fun inviteList() { + val sut = DeepLinkCreator() + assertThat(sut.inviteList(A_SESSION_ID)) + .isEqualTo("elementx://open/@alice:server.org/invites") + } +} diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt new file mode 100644 index 0000000000..553850a4d6 --- /dev/null +++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.deeplink + +import android.content.Intent +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.tests.testutils.assertThrowsInDebug +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeeplinkParserTest { + companion object { + const val A_URI = + "elementx://open/@alice:server.org" + const val A_URI_WITH_ROOM = + "elementx://open/@alice:server.org/!aRoomId:domain" + const val A_URI_WITH_ROOM_WITH_THREAD = + "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId" + const val A_URI_FOR_INVITE_LIST = + "elementx://open/@alice:server.org/invites" + } + + private val sut = DeeplinkParser() + + @Test + fun `nominal cases`() { + assertThat(sut.getFromIntent(createIntent(A_URI))) + .isEqualTo(DeeplinkData.Root(A_SESSION_ID)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM))) + .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD))) + .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) + assertThat(sut.getFromIntent(createIntent(A_URI_FOR_INVITE_LIST))) + .isEqualTo(DeeplinkData.InviteList(A_SESSION_ID)) + } + + @Test + fun `error cases`() { + val sut = DeeplinkParser() + // Bad scheme + assertThat(sut.getFromIntent(createIntent("x://open/@alice:server.org"))).isNull() + // Bad host + assertThat(sut.getFromIntent(createIntent("elementx://close/@alice:server.org"))).isNull() + // No session Id + assertThat(sut.getFromIntent(createIntent("elementx://open"))).isNull() + + assertThrowsInDebug { + // Invalid sessionId + sut.getFromIntent(createIntent("elementx://open/alice:server.org")) + } + assertThrowsInDebug { + // Empty sessionId + sut.getFromIntent(createIntent("elementx://open//")) + } + } + + private fun createIntent(uri: String): Intent { + return Intent().apply { + action = Intent.ACTION_VIEW + data = uri.toUri() + } + } +} diff --git a/libraries/designsystem/.gitignore b/libraries/designsystem/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/designsystem/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts new file mode 100644 index 0000000000..35b7a65cb4 --- /dev/null +++ b/libraries/designsystem/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.designsystem" + + buildFeatures { + buildConfig = true + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + consumerProguardFiles("consumer-rules.pro") + } + } + + dependencies { + api(projects.libraries.theme) + // Should not be there, but this is a POC + implementation(libs.coil.compose) + implementation(libs.vanniktech.blurhash) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) + kspTest(libs.showkase.processor) + } +} diff --git a/libraries/designsystem/consumer-rules.pro b/libraries/designsystem/consumer-rules.pro new file mode 100644 index 0000000000..dabf5661a4 --- /dev/null +++ b/libraries/designsystem/consumer-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModuleCodegen { } diff --git a/libraries/designsystem/src/main/AndroidManifest.xml b/libraries/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..19db0c3d57 --- /dev/null +++ b/libraries/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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> + +</manifest> diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ColorUtil.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ColorUtil.kt new file mode 100644 index 0000000000..7e2154b4db --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ColorUtil.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +@Composable +fun Boolean.toEnabledColor(): Color { + return if (this) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.40f) + } +} + +@Composable +fun Boolean.toSecondaryEnabledColor(): Color { + return if (this) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.secondary.copy(alpha = 0.40f) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt new file mode 100644 index 0000000000..0e33567129 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem + +object VectorIcons { + val Copy = R.drawable.ic_content_copy + val Forward = R.drawable.ic_forward + val Delete = R.drawable.ic_delete + val Reply = R.drawable.ic_reply + val Edit = R.drawable.ic_edit + val DoorOpen = R.drawable.ic_door_open_24 + val DeveloperMode = R.drawable.ic_developer_mode + val ReportContent = R.drawable.ic_report_content +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt new file mode 100644 index 0000000000..94e50d5953 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.atoms + +import android.graphics.BlurMaskFilter +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun ElementLogoAtom( + size: ElementLogoAtomSize, + modifier: Modifier = Modifier, + darkTheme: Boolean = isSystemInDarkTheme(), +) { + val blur = if (darkTheme) 160.dp else 24.dp + //box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280; + val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight + val backgroundColor = if (darkTheme) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f) + val borderColor = if (darkTheme) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f) + Box( + modifier = modifier + .size(size.outerSize) + .border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(size.outerSize) + .shapeShadow( + color = shadowColor, + cornerRadius = size.cornerRadius, + blurRadius = size.shadowRadius, + offsetY = 8.dp, + ) + ) + Box( + Modifier + .clip(RoundedCornerShape(size.cornerRadius)) + .size(size.outerSize) + .background(backgroundColor) + .blur(blur) + ) + Image( + modifier = Modifier.size(size.logoSize), + painter = painterResource(id = R.drawable.element_logo), + contentDescription = null + ) + } +} + +sealed class ElementLogoAtomSize( + val outerSize: Dp, + val logoSize: Dp, + val cornerRadius: Dp, + val borderWidth: Dp, + val shadowColorDark: Color, + val shadowColorLight: Color, + val shadowRadius: Dp, +) { + object Medium : ElementLogoAtomSize( + outerSize = 120.dp, + logoSize = 83.5.dp, + cornerRadius = 33.dp, + borderWidth = 0.38.dp, + shadowColorDark = Color.Black.copy(alpha = 0.4f), + shadowColorLight = Color(0x401B1D22), + shadowRadius = 32.dp, + ) + + object Large : ElementLogoAtomSize( + outerSize = 158.dp, + logoSize = 110.dp, + cornerRadius = 44.dp, + borderWidth = 0.5.dp, + shadowColorDark = Color.Black, + shadowColorLight = Color(0x801B1D22), + shadowRadius = 60.dp, + ) +} + +fun Modifier.shapeShadow( + color: Color = Color.Black, + cornerRadius: Dp = 0.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 0.dp, +) = then( + drawBehind { + drawIntoCanvas { canvas -> + val path = Path().apply { + addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) + } + + clipPath(path, ClipOp.Difference) { + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + if (blurRadius != 0.dp) { + frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL)) + } + frameworkPaint.color = color.toArgb() + + val leftPixel = offsetX.toPx() + val topPixel = offsetY.toPx() + val rightPixel = size.width + topPixel + val bottomPixel = size.height + leftPixel + + canvas.drawRect( + left = leftPixel, + top = topPixel, + right = rightPixel, + bottom = bottomPixel, + paint = paint, + ) + } + } + } +) + +@Composable +@DayNightPreviews +internal fun ElementLogoAtomMediumPreview() { + ContentToPreview(ElementLogoAtomSize.Medium) +} + +@Composable +@DayNightPreviews +internal fun ElementLogoAtomLargePreview() { + ContentToPreview(ElementLogoAtomSize.Large) +} + +@Composable +private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize) { + ElementPreview { + Box( + Modifier + .size(elementLogoAtomSize.outerSize + elementLogoAtomSize.shadowRadius * 2) + .background(ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center + ) { + ElementLogoAtom(elementLogoAtomSize) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt new file mode 100644 index 0000000000..6b20c96880 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun InfoListItemMolecule( + message: @Composable () -> Unit, + position: InfoListItemPosition, + backgroundColor: Color, + modifier: Modifier = Modifier, + icon: @Composable () -> Unit = {}, +) { + val radius = 14.dp + val backgroundShape = remember(position) { + when (position) { + InfoListItemPosition.Single -> RoundedCornerShape(radius) + InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius) + InfoListItemPosition.Middle -> RoundedCornerShape(0.dp) + InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius) + } + } + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = backgroundColor, + shape = backgroundShape, + ) + .padding(vertical = 12.dp, horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + icon() + message() + } +} + +@DayNightPreviews +@Composable +fun InfoListItemMoleculePreview() { + ElementPreview { + val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoListItemMolecule( + message = { Text("A single item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Single, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A top item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Top, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A middle item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Middle, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A bottom item") }, + icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) }, + position = InfoListItemPosition.Bottom, + backgroundColor = color, + ) + } + } +} + +enum class InfoListItemPosition { + Top, + Middle, + Bottom, + Single, +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaceholderAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaceholderAtom.kt new file mode 100644 index 0000000000..75ed007d97 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaceholderAtom.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PlaceholderAtom( + width: Dp, + height: Dp, + modifier: Modifier = Modifier, + color: Color = ElementTheme.colors.placeholderBackground, +) { + Box( + modifier = modifier + .width(width) + .height(height) + .background( + color = color, + shape = RoundedCornerShape(size = height / 2) + ) + ) +} + +@Preview +@Composable +internal fun PlaceholderAtomLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun PlaceholderAtomDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + // Use a Red background to see the shape + Box(modifier = Modifier.background(color = Color.Red)) { + PlaceholderAtom( + width = 80.dp, + height = 12.dp + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt new file mode 100644 index 0000000000..f1117c6274 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial +import io.element.android.libraries.theme.ElementTheme + +/** + * RoundedIconAtom is an atom which displays an icon inside a rounded container. + * + * @param modifier the modifier to apply to this layout + * @param size the size of the icon + * @param resourceId the resource id of the icon to display, exclusive with [imageVector] + * @param imageVector the image vector of the icon to display, exclusive with [resourceId] + * @param tint the tint to apply to the icon + */ +@Composable +fun RoundedIconAtom( + modifier: Modifier = Modifier, + size: RoundedIconAtomSize = RoundedIconAtomSize.Large, + resourceId: Int? = null, + imageVector: ImageVector? = null, + tint: Color = MaterialTheme.colorScheme.secondary +) { + Box( + modifier = modifier + .size(size.toContainerSize()) + .background( + color = ElementTheme.colors.temporaryColorBgSpecial, + shape = RoundedCornerShape(size.toCornerSize()) + ) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(size.toIconSize()), + tint = tint, + resourceId = resourceId, + imageVector = imageVector, + contentDescription = "", + ) + } +} + +private fun RoundedIconAtomSize.toContainerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 30.dp + RoundedIconAtomSize.Large -> 70.dp + } +} + +private fun RoundedIconAtomSize.toCornerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 8.dp + RoundedIconAtomSize.Large -> 14.dp + } +} + +private fun RoundedIconAtomSize.toIconSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 16.dp + RoundedIconAtomSize.Large -> 48.dp + } +} + +@Preview +@Composable +internal fun RoundedIconAtomLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RoundedIconAtomDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = Icons.Filled.Home, + ) + RoundedIconAtom( + size = RoundedIconAtomSize.Large, + imageVector = Icons.Filled.Home, + ) + } +} + +enum class RoundedIconAtomSize { + Medium, + Large +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt new file mode 100644 index 0000000000..3d4019e564 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.unreadIndicator +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun UnreadIndicatorAtom( + modifier: Modifier = Modifier, + size: Dp = 12.dp, + color: Color = ElementTheme.colors.unreadIndicator, + isVisible: Boolean = true, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(if (isVisible) color else Color.Transparent) + ) +} + +@Preview +@Composable +internal fun UnreadIndicatorAtomLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun UnreadIndicatorAtomDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + UnreadIndicatorAtom() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt new file mode 100644 index 0000000000..a8ad97a950 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton + +@Composable +fun ButtonColumnMolecule( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + content() + } +} + +@Preview +@Composable +internal fun ButtonColumnMoleculeLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun ButtonColumnMoleculeDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + ButtonColumnMolecule { + Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text(text = "Button") + } + TextButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text(text = "TextButton") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt new file mode 100644 index 0000000000..b8b5a2146f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton + +@Composable +fun ButtonRowMolecule( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + content() + } +} + +@Preview +@Composable +internal fun ButtonRowMoleculeLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun ButtonRowMoleculeDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + ButtonRowMolecule { + TextButton(onClick = { }) { + Text("Button 1") + } + TextButton(onClick = { }) { + Text("Button 2") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt new file mode 100644 index 0000000000..5e766c6e94 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle. + * + * @param title the title to display + * @param subTitle the subtitle to display + * @param modifier the modifier to apply to this layout + * @param iconResourceId the resource id of the icon to display, exclusive with [iconImageVector] + * @param iconImageVector the image vector of the icon to display, exclusive with [iconResourceId] + * @param iconTint the tint to apply to the icon + */ +@Composable +fun IconTitleSubtitleMolecule( + title: String, + subTitle: String, + modifier: Modifier = Modifier, + iconResourceId: Int? = null, + iconImageVector: ImageVector? = null, + iconTint: Color = MaterialTheme.colorScheme.primary, +) { + Column(modifier) { + RoundedIconAtom( + modifier = Modifier + .align(Alignment.CenterHorizontally), + size = RoundedIconAtomSize.Large, + resourceId = iconResourceId, + imageVector = iconImageVector, + tint = iconTint, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + modifier = Modifier + .fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontHeadingMdBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = subTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.secondary, + ) + } +} + +@Preview +@Composable +internal fun IconTitleSubtitleMoleculeLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun IconTitleSubtitleMoleculeDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + IconTitleSubtitleMolecule( + iconResourceId = R.drawable.ic_edit, + title = "Title", + subTitle = "Sub iitle", + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt new file mode 100644 index 0000000000..1bc82eb3b2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.molecules + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule +import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun InfoListOrganism( + items: ImmutableList<InfoListItem>, + backgroundColor: Color, + modifier: Modifier = Modifier, + iconTint: Color = LocalContentColor.current, + textStyle: TextStyle = LocalTextStyle.current, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp), +) { + Column( + modifier = modifier, + verticalArrangement = verticalArrangement, + ) { + for ((index, item) in items.withIndex()) { + val position = when { + items.size == 1 -> InfoListItemPosition.Single + index == 0 -> InfoListItemPosition.Top + index == items.size - 1 -> InfoListItemPosition.Bottom + else -> InfoListItemPosition.Middle + } + InfoListItemMolecule( + message = { + Text( + text = item.message, + style = textStyle, + color = ElementTheme.colors.textPrimary, + ) + }, + icon = { + if (item.iconId != null) { + Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint) + } else if (item.iconVector != null) { + Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint) + } else { + item.iconComposable() + } + }, + position = position, + backgroundColor = backgroundColor, + ) + } + } +} + +data class InfoListItem( + val message: String, + @DrawableRes val iconId: Int? = null, + val iconVector: ImageVector? = null, + val iconComposable: @Composable () -> Unit = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt new file mode 100644 index 0000000000..b49638c55c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.pages + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * @param modifier Classical modifier. + * @param header optional header. + * @param footer optional footer. + * @param content main content. + */ +@Composable +fun HeaderFooterPage( + modifier: Modifier = Modifier, + header: @Composable () -> Unit = {}, + footer: @Composable () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .padding(all = 20.dp), + ) { + // Header + header() + // Content + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + content() + } + // Footer + footer() + } +} + +@Preview +@Composable +internal fun HeaderFooterPageLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun HeaderFooterPageDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + HeaderFooterPage( + content = { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Content", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + }, + header = { + Box( + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Header", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + }, + footer = { + Box( + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Footer", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + } + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt new file mode 100644 index 0000000000..c411802d44 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.atomic.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * Page for onboarding screens, with content and optional footer. + * + * Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0 + * @param modifier Classical modifier. + * @param contentAlignment horizontal alignment of the contents. + * @param footer optional footer. + * @param content main content. + */ +@Composable +fun OnBoardingPage( + modifier: Modifier = Modifier, + contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + footer: @Composable () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxSize() + ) { + // BG + Image( + modifier = Modifier + .fillMaxSize(), + painter = painterResource(id = R.drawable.onboarding_bg), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(vertical = 16.dp), + ) { + // Content + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp) + .fillMaxWidth(), + horizontalAlignment = contentAlignment, + ) { + content() + } + // Footer + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + footer() + } + } + } +} + +@DayNightPreviews +@Composable +internal fun OnBoardingPagePreview() = ElementPreview { + OnBoardingPage( + content = { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Content", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + }, + footer = { + Box( + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Footer", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + } + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt new file mode 100644 index 0000000000..0ddd0b7346 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BlurHashAsyncImage.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage +import com.vanniktech.blurhash.BlurHash + +@Composable +fun BlurHashAsyncImage( + model: Any?, + blurHash: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + contentDescription: String? = null, +) { + var isLoading by rememberSaveable(model) { mutableStateOf(true) } + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = model, + contentScale = contentScale, + contentDescription = contentDescription, + onSuccess = { isLoading = false } + ) + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + BlurHashImage( + blurHash = blurHash, + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + ) + } + } +} + +@Composable +fun BlurHashImage( + blurHash: String?, + modifier: Modifier = Modifier, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + if (blurHash == null) return + val bitmapState = remember(blurHash) { + mutableStateOf( + // Build a small blurhash image so that it's fast + BlurHash.decode(blurHash, 10, 10) + ) + } + DisposableEffect(blurHash) { + onDispose { + bitmapState.value?.recycle() + } + } + bitmapState.value?.let { bitmap -> + Image( + modifier = modifier.fillMaxSize(), + bitmap = bitmap.asImageBitmap(), + contentScale = contentScale, + contentDescription = contentDescription + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt new file mode 100644 index 0000000000..eb9749498a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +@OptIn(ExperimentalTextApi::class) +@Composable +fun ClickableLinkText( + text: AnnotatedString, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + linkAnnotationTag: String = "", + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(), +) { + val uriHandler = LocalUriHandler.current + val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) } + val pressIndicator = Modifier.pointerInput(onClick) { + detectTapGestures( + onPress = { offset: Offset -> + val pressInteraction = PressInteraction.Press(offset) + interactionSource.emit(pressInteraction) + val isReleased = tryAwaitRelease() + if (isReleased) { + interactionSource.emit(PressInteraction.Release(pressInteraction)) + } else { + interactionSource.emit(PressInteraction.Cancel(pressInteraction)) + } + }, + onLongPress = { + onLongClick() + } + ) { offset -> + layoutResult.value?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + val linkUrlAnnotations = text.getUrlAnnotations(position, position) + .map { AnnotatedString.Range(it.item.url, it.start, it.end, linkAnnotationTag) } + val linkStringAnnotations = linkUrlAnnotations + + text.getStringAnnotations(linkAnnotationTag, position, position) + if (linkStringAnnotations.isEmpty()) { + onClick() + } else { + uriHandler.openUri(linkStringAnnotations.first().item) + } + } + } + } + Text( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + onTextLayout = { + layoutResult.value = it + }, + inlineContent = inlineContent, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Preview(group = PreviewGroup.Text) +@Composable +internal fun ClickableLinkTextPreview() = + ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + ClickableLinkText( + text = AnnotatedString("Hello", ParagraphStyle()), + linkAnnotationTag = "", + onClick = {}, + onLongClick = {}, + interactionSource = MutableInteractionSource(), + ) +} + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt new file mode 100644 index 0000000000..8804066da8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** + * Used to create a column where all children have the same width. + * It will first measure all children, get the largest width and re-measure all children with this width as the minWidth. + * + * *Note*: If all children already have the same width, it skips the 2nd measuring and acts like a normal Column. + */ +@Composable +fun EqualWidthColumn( + modifier: Modifier = Modifier, + spacing: Dp = 0.dp, + content: @Composable () -> Unit +) { + SubcomposeLayout(modifier = modifier) { constraints -> + val measurables = subcompose(0, content).map { it.measure(constraints) } + val maxWidth = measurables.maxOf { it.width } + val newConstraints = constraints.copy(minWidth = maxWidth) + val newMeasurables = if (measurables.all { it.width == maxWidth }) { + // Skip re-measuring if all children have the same width + measurables + } else { + // Re-measure with the largest width as the minWidth to have all children constrained to the same width + subcompose(1, content).map { it.measure(newConstraints) } + } + val totalHeight = (newMeasurables.sumOf { it.height } + spacing.toPx() * (newMeasurables.size - 1)).roundToInt() + layout(maxWidth, totalHeight) { + var yPosition = 0 + newMeasurables.forEach { measurable -> + measurable.placeRelative(0, yPosition) + yPosition += measurable.height + spacing.roundToPx() + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledCheckbox.kt new file mode 100644 index 0000000000..2c9c776473 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledCheckbox.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun LabelledCheckbox( + checked: Boolean, + text: String, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun LabelledCheckboxPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + LabelledCheckbox( + checked = true, + text = "Some text", + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt new file mode 100644 index 0000000000..cc1d92ccd8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun LabelledTextField( + label: String, + value: String, + modifier: Modifier = Modifier, + placeholder: String? = null, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + onValueChange: (String) -> Unit = {}, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular, + color = MaterialTheme.colorScheme.primary, + text = label + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = value, + placeholder = placeholder?.let { { Text(placeholder) } }, + onValueChange = onValueChange, + singleLine = singleLine, + maxLines = maxLines, + ) + } +} + +@Preview +@Composable +fun LabelledTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + LabelledTextField( + label = "Room name", + value = "", + placeholder = "e.g. Product Sprint", + ) + LabelledTextField( + label = "Room name", + value = "a room name", + placeholder = "e.g. Product Sprint", + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt new file mode 100644 index 0000000000..c6af3e1cdf --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PinIcon( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(ElementTheme.colors.bgSubtlePrimary) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .width(22.dp), + resourceId = R.drawable.pin, + contentDescription = null, + tint = Color.Unspecified, + ) + } +} + +@DayNightPreviews +@Composable +fun PinIconPreview() = ElementPreview { + PinIcon() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt new file mode 100644 index 0000000000..a5ad996ea7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.element.android.libraries.designsystem.components.dialogs.DialogPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber + +@Composable +fun ProgressDialog( + modifier: Modifier = Modifier, + text: String? = null, + type: ProgressDialogType = ProgressDialogType.Indeterminate, + isCancellable: Boolean = false, + onDismissRequest: () -> Unit = {}, +) { + DisposableEffect(Unit) { + onDispose { + Timber.v("OnDispose progressDialog") + } + } + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) { + ProgressDialogContent( + modifier = modifier, + text = text, + isCancellable = isCancellable, + onCancelClicked = onDismissRequest, + progressIndicator = { + when (type) { + is ProgressDialogType.Indeterminate -> { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } + is ProgressDialogType.Determinate -> { + CircularProgressIndicator( + progress = type.progress, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + ) + } +} + +@Immutable +sealed interface ProgressDialogType { + data class Determinate(val progress: Float) : ProgressDialogType + object Indeterminate : ProgressDialogType +} + +@Composable +private fun ProgressDialogContent( + modifier: Modifier = Modifier, + text: String? = null, + isCancellable: Boolean = false, + onCancelClicked: () -> Unit = {}, + progressIndicator: @Composable () -> Unit = { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = 38.dp, bottom = 32.dp, start = 40.dp, end = 40.dp) + ) { + progressIndicator() + if (!text.isNullOrBlank()) { + Spacer(modifier = Modifier.height(22.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + ) + } + if (isCancellable) { + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomEnd + ) { + TextButton(onClick = onCancelClicked) { + Text(stringResource(id = CommonStrings.action_cancel)) + } + } + } + } + } +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ProgressDialogPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + DialogPreview { + ProgressDialogContent(text = "test dialog content", isCancellable = true) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt new file mode 100644 index 0000000000..b5aa9b85d3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.async + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AsyncFailure( + throwable: Throwable, + onRetry: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = throwable.message ?: stringResource(id = CommonStrings.error_unknown)) + if (onRetry != null) { + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text(text = stringResource(id = CommonStrings.action_retry)) + } + } + } +} + +@Preview +@Composable +internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AsyncFailure( + throwable = IllegalStateException("An error occurred"), + onRetry = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt new file mode 100644 index 0000000000..f63d34fd9b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.async + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator + +@Composable +fun AsyncLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AsyncLoading() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt new file mode 100644 index 0000000000..b28e52a5ff --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import timber.log.Timber + +@Composable +fun Avatar( + avatarData: AvatarData, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val commonModifier = modifier + .size(avatarData.size.dp) + .clip(CircleShape) + if (avatarData.url.isNullOrBlank()) { + InitialsAvatar( + avatarData = avatarData, + modifier = commonModifier, + ) + } else { + ImageAvatar( + avatarData = avatarData, + modifier = commonModifier, + contentDescription = contentDescription, + ) + } +} + +@Composable +private fun ImageAvatar( + avatarData: AvatarData, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + AsyncImage( + model = avatarData, + onError = { + Timber.e(it.result.throwable, "Error loading avatar $it\n${it.result}") + }, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + placeholder = debugPlaceholderAvatar(), + modifier = modifier + ) +} + +@Composable +private fun InitialsAvatar( + avatarData: AvatarData, + modifier: Modifier = Modifier, +) { + // Use temporary color for default avatar background + val avatarColor = ElementTheme.colors.bgActionPrimaryDisabled + Box( + modifier.background(color = avatarColor), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = avatarData.initial, + style = ElementTheme.typography.fontBodyMdRegular.copy(fontSize = avatarData.size.dp.toSp() / 2), + color = Color.White, + ) + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) = + ElementThemedPreview { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Avatar(avatarData) + Text(text = avatarData.size.name + " " + avatarData.size.dp) + } + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt new file mode 100644 index 0000000000..9ba984f565 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.runtime.Immutable + +@Immutable +data class AvatarData( + val id: String, + val name: String?, + val url: String? = null, + val size: AvatarSize, +) { + + val initial by lazy { + (name?.takeIf { it.isNotBlank() } ?: id) + .let { dn -> + var startIndex = 0 + val initial = dn[startIndex] + + if (initial in listOf('@', '#', '+') && dn.length > 1) { + startIndex++ + } + + var length = 1 + var first = dn[startIndex] + + // LEFT-TO-RIGHT MARK + if (dn.length >= 2 && 0x200e == first.code) { + startIndex++ + first = dn[startIndex] + } + + // check if it’s the start of a surrogate pair + if (first.code in 0xD800..0xDBFF && dn.length > startIndex + 1) { + val second = dn[startIndex + 1] + if (second.code in 0xDC00..0xDFFF) { + length++ + } + } + + dn.substring(startIndex, startIndex + length) + } + .uppercase() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt new file mode 100644 index 0000000000..1727fffd1c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AvatarDataProvider : PreviewParameterProvider<AvatarData> { + override val values: Sequence<AvatarData> + get() { + AvatarSize.values() + .also { it.sortBy { item -> item.name } } + .asSequence() + return AvatarSize.values().asSequence().map { + sequenceOf( + anAvatarData(size = it), + anAvatarData(size = it).copy(name = null), + anAvatarData(size = it).copy(url = "aUrl"), + ) + } + .flatten() + } +} + +fun anAvatarData( + // Let's the id not start with a 'a'. + id: String = "@id_of_alice:server.org", + name: String = "Alice", + size: AvatarSize = AvatarSize.RoomListItem, +) = AvatarData( + id = id, + name = name, + size = size, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt new file mode 100644 index 0000000000..2438e5f017 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +enum class AvatarSize(val dp: Dp) { + CurrentUserTopBar(28.dp), + + RoomHeader(96.dp), + RoomListItem(52.dp), + + ForwardRoomListItem(36.dp), + + UserPreference(56.dp), + + UserHeader(96.dp), + UserListItem(36.dp), + + SelectedUser(56.dp), + SelectedRoom(56.dp), + + TimelineRoom(32.dp), + TimelineSender(32.dp), + + MessageActionSender(32.dp), + + RoomInviteItem(52.dp), + InviteSender(16.dp), +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/BackButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/BackButton.kt new file mode 100644 index 0000000000..b16013775c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/BackButton.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun BackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + imageVector: ImageVector = Icons.Default.ArrowBack, + contentDescription: String = stringResource(CommonStrings.action_back), + enabled: Boolean = true, +) { + IconButton( + modifier = modifier, + onClick = onClick, + enabled = enabled, + ) { + Icon(imageVector = imageVector, contentDescription = contentDescription) + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun BackButtonPreview() = ElementThemedPreview { + Column { + BackButton(onClick = { }, enabled = true, contentDescription = "Back") + BackButton(onClick = { }, enabled = false, contentDescription = "Back") + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt new file mode 100644 index 0000000000..06702de253 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ElementButtonDefaults +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * A component that will display a button with an indeterminate circular progressbar. + * When [showProgress] is true: + * - A circular progressbar is displayed. + * - [text] is replaced by [progressText], if defined. + * - [onClick] gets disabled. + */ +@Composable +fun ButtonWithProgress( + text: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + showProgress: Boolean = false, + progressText: String? = text, + enabled: Boolean = true, + shape: Shape = ElementButtonDefaults.shape, + colors: ButtonColors = ElementButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Button( + onClick = { + if (!showProgress) { + onClick() + } + }, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource, + ) { + if (showProgress) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + if (progressText != null) { + Spacer(Modifier.width(10.dp)) + Text(progressText, style = ElementTheme.typography.aliasButtonText) + } + } else if (text != null) { + Text(text, style = ElementTheme.typography.aliasButtonText) + } + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun ButtonWithProgressPreview() = ElementThemedPreview { + ButtonWithProgress( + text = "Button with progress", + onClick = {}, + showProgress = true, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt new file mode 100644 index 0000000000..c97c1cc59e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun MainActionButton( + title: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentDescription: String = title, +) { + val ripple = rememberRipple(bounded = false) + val interactionSource = MutableInteractionSource() + Column( + modifier.clickable( + enabled = enabled, + interactionSource = interactionSource, + onClick = onClick, + indication = ripple + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val tintColor = if (enabled) LocalContentColor.current else MaterialTheme.colorScheme.secondary + Icon( + icon, + contentDescription = contentDescription, + tint = tintColor, + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + title, + style = ElementTheme.typography.fontBodyMdMedium, + color = tintColor, + ) + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun MainActionButtonPreview() { + ElementThemedPreview { + ContentsToPreview() + } +} + +@Composable +private fun ContentsToPreview() { + Row(Modifier.padding(10.dp)) { + MainActionButton(title = "Share", icon = Icons.Outlined.Share, onClick = { }) + Spacer(modifier = Modifier.width(20.dp)) + MainActionButton(title = "Share", icon = Icons.Outlined.Share, onClick = { }, enabled = false) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt new file mode 100644 index 0000000000..c1e8b0c055 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import kotlin.math.max + +@Composable +internal fun SimpleAlertDialogContent( + content: String, + cancelText: String, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + submitText: String? = null, + onSubmitClicked: () -> Unit = {}, + thirdButtonText: String? = null, + onThirdButtonClicked: () -> Unit = {}, + emphasizeSubmitButton: Boolean = false, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, +) { + AlertDialogContent( + buttons = { + AlertDialogFlowRow( + mainAxisSpacing = ButtonsMainAxisSpacing, + crossAxisSpacing = ButtonsCrossAxisSpacing + ) { + if (thirdButtonText != null) { + // If there is a 3rd item it should be at the end of the dialog + // Having this 3rd action is discouraged, see https://m3.material.io/components/dialogs/guidelines#e13b68f5-e367-4275-ad6f-c552ee8e358f + TextButton(onClick = onThirdButtonClicked) { + Text( + text = thirdButtonText, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } + TextButton(onClick = onCancelClicked) { + Text( + text = cancelText, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + if (submitText != null) { + TextButton(onClick = onSubmitClicked) { + Text( + text = submitText, + style = if (emphasizeSubmitButton) { + ElementTheme.typography.fontBodyMdMedium + } else { + ElementTheme.typography.fontBodyMdRegular + } + ) + } + } + } + }, + modifier = modifier, + title = { + if (title != null) { + Text( + text = title, + style = ElementTheme.typography.fontHeadingSmRegular, + ) + } + }, + text = { + Text( + text = content, + style = ElementTheme.typography.fontBodyMdRegular, + ) + }, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + icon = icon, + // Note that a button content color is provided here from the dialog's token, but in + // most cases, TextButtons should be used for dismiss and confirm buttons. + // TextButtons will not consume this provided content color value, and will used their + // own defined or default colors. + buttonContentColor = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +internal fun AlertDialogContent( + buttons: @Composable () -> Unit, + icon: (@Composable () -> Unit)?, + title: (@Composable () -> Unit)?, + text: @Composable (() -> Unit)?, + shape: Shape, + containerColor: Color, + tonalElevation: Dp, + buttonContentColor: Color, + iconContentColor: Color, + titleContentColor: Color, + textContentColor: Color, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = shape, + color = containerColor, + tonalElevation = tonalElevation, + ) { + Column( + modifier = Modifier.padding(DialogPadding) + ) { + icon?.let { + CompositionLocalProvider(LocalContentColor provides iconContentColor) { + Box( + Modifier + .padding(IconPadding) + .align(Alignment.CenterHorizontally) + ) { + icon() + } + } + } + title?.let { + CompositionLocalProvider(LocalContentColor provides titleContentColor) { + val textStyle = MaterialTheme.typography.headlineSmall + ProvideTextStyle(textStyle) { + Box( + // Align the title to the center when an icon is present. + Modifier + .padding(TitlePadding) + .align( + if (icon == null) { + Alignment.Start + } else { + Alignment.CenterHorizontally + } + ) + ) { + title() + } + } + } + } + text?.let { + CompositionLocalProvider(LocalContentColor provides textContentColor) { + val textStyle = + MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + Modifier + .weight(weight = 1f, fill = false) + .padding(TextPadding) + .align(Alignment.Start) + ) { + text() + } + } + } + } + Box(modifier = Modifier.align(Alignment.End)) { + CompositionLocalProvider(LocalContentColor provides buttonContentColor) { + val textStyle = + MaterialTheme.typography.labelLarge + ProvideTextStyle(value = textStyle, content = buttons) + } + } + } + } +} + +/** + * Simple clone of FlowRow that arranges its children in a horizontal flow with limited + * customization. + */ +@Composable +internal fun AlertDialogFlowRow( + mainAxisSpacing: Dp, + crossAxisSpacing: Dp, + content: @Composable () -> Unit +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf<List<Placeable>>() + val crossAxisSizes = mutableListOf<Int>() + val crossAxisPositions = mutableListOf<Int>() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf<Placeable>() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + // Return whether the placeable can be added to the current sequence. + fun canAddToCurrentSequence(placeable: Placeable) = + currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() + + placeable.width <= constraints.maxWidth + + // Store current sequence information and start a new sequence. + fun startNewSequence() { + if (sequences.isNotEmpty()) { + crossAxisSpace += crossAxisSpacing.roundToPx() + } + sequences += currentSequence.toList() + crossAxisSizes += currentCrossAxisSize + crossAxisPositions += crossAxisSpace + + crossAxisSpace += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + + currentSequence.clear() + currentMainAxisSize = 0 + currentCrossAxisSize = 0 + } + + for (measurable in measurables) { + // Ask the child for its preferred size. + val placeable = measurable.measure(constraints) + + // Start a new sequence if there is not enough space. + if (!canAddToCurrentSequence(placeable)) startNewSequence() + + // Add the child to the current sequence. + if (currentSequence.isNotEmpty()) { + currentMainAxisSize += mainAxisSpacing.roundToPx() + } + currentSequence.add(placeable) + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + if (currentSequence.isNotEmpty()) startNewSequence() + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + val layoutWidth = mainAxisLayoutSize + + val layoutHeight = crossAxisLayoutSize + + layout(layoutWidth, layoutHeight) { + sequences.forEachIndexed { i, placeables -> + val childrenMainAxisSizes = IntArray(placeables.size) { j -> + placeables[j].width + + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 + } + val arrangement = Arrangement.Bottom + // TODO(soboleva): rtl support + // Handle vertical direction + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange(mainAxisLayoutSize, childrenMainAxisSizes, mainAxisPositions) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], + y = crossAxisPositions[i] + ) + } + } + } + } +} + +@Composable +internal fun DialogPreview(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth) + .padding(20.dp), + propagateMinConstraints = true + ) { + content() + } +} + +// Paddings for each of the dialog's parts. +private val DialogPadding = PaddingValues(all = 24.dp) +private val IconPadding = PaddingValues(bottom = 16.dp) +private val TitlePadding = PaddingValues(bottom = 16.dp) +private val TextPadding = PaddingValues(bottom = 24.dp) + +internal val ButtonsMainAxisSpacing = 8.dp +internal val ButtonsCrossAxisSpacing = 12.dp + +internal val DialogMinWidth = 280.dp +internal val DialogMaxWidth = 560.dp diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt new file mode 100644 index 0000000000..b11c9f878d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.utils.BooleanProvider +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmationDialog( + content: String, + onSubmitClicked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + submitText: String = stringResource(id = CommonStrings.action_ok), + cancelText: String = stringResource(id = CommonStrings.action_cancel), + thirdButtonText: String? = null, + emphasizeSubmitButton: Boolean = false, + onCancelClicked: () -> Unit = onDismiss, + onThirdButtonClicked: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + // According to the design team, `primary` should be used here instead of the default `onSurface` + titleContentColor: Color = MaterialTheme.colorScheme.primary, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + AlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + ConfirmationDialogContent( + title = title, + content = content, + submitText = submitText, + cancelText = cancelText, + thirdButtonText = thirdButtonText, + onSubmitClicked = onSubmitClicked, + onCancelClicked = onCancelClicked, + onThirdButtonClicked = onThirdButtonClicked, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + emphasizeSubmitButton = emphasizeSubmitButton, + ) + } +} + +@Composable +private fun ConfirmationDialogContent( + content: String, + submitText: String, + cancelText: String, + onSubmitClicked: () -> Unit, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + thirdButtonText: String? = null, + onThirdButtonClicked: () -> Unit = {}, + emphasizeSubmitButton: Boolean = false, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, +) { + SimpleAlertDialogContent( + modifier = modifier, + title = title, + content = content, + submitText = submitText, + onSubmitClicked = onSubmitClicked, + cancelText = cancelText, + onCancelClicked = onCancelClicked, + thirdButtonText = thirdButtonText, + onThirdButtonClicked = onThirdButtonClicked, + emphasizeSubmitButton = emphasizeSubmitButton, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + icon = icon, + ) +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ConfirmationDialogPreview(@PreviewParameter(BooleanProvider::class) emphasizeSubmitButton: Boolean) = + ElementThemedPreview { + DialogPreview { + ConfirmationDialogContent( + content = "Content", + title = "Title", + submitText = "OK", + cancelText = "Cancel", + thirdButtonText = "Disable", + onSubmitClicked = {}, + onCancelClicked = {}, + emphasizeSubmitButton = emphasizeSubmitButton, + ) + } + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt new file mode 100644 index 0000000000..d69281b6e9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorDialog( + content: String, + modifier: Modifier = Modifier, + title: String = ErrorDialogDefaults.title, + submitText: String = ErrorDialogDefaults.submitText, + onDismiss: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + AlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + ErrorDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitText = onDismiss, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + ) + } +} + +@Composable +private fun ErrorDialogContent( + content: String, + modifier: Modifier = Modifier, + title: String = ErrorDialogDefaults.title, + submitText: String = ErrorDialogDefaults.submitText, + onSubmitText: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + SimpleAlertDialogContent( + modifier = modifier, + title = title, + content = content, + cancelText = submitText, + onCancelClicked = onSubmitText, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + ) +} + +object ErrorDialogDefaults { + val title: String @Composable get() = stringResource(id = CommonStrings.dialog_title_error) + val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok) +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ErrorDialogPreview() { + ElementThemedPreview { + DialogPreview { + ErrorDialogContent( + content = "Content", + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt new file mode 100644 index 0000000000..5e22779085 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RetryDialog( + content: String, + modifier: Modifier = Modifier, + title: String = RetryDialogDefaults.title, + retryText: String = RetryDialogDefaults.retryText, + dismissText: String = RetryDialogDefaults.dismissText, + onRetry: () -> Unit = {}, + onDismiss: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text( + text = title, + style = ElementTheme.typography.fontHeadingSmRegular, + ) + }, + text = { + Text( + text = content, + style = ElementTheme.typography.fontBodyMdRegular, + ) + }, + confirmButton = { + TextButton(onClick = onRetry) { + Text( + text = retryText, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = dismissText, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + }, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + ) +} + +@Composable +private fun RetryDialogContent( + content: String, + modifier: Modifier = Modifier, + title: String = RetryDialogDefaults.title, + retryText: String = RetryDialogDefaults.retryText, + dismissText: String = RetryDialogDefaults.dismissText, + onRetry: () -> Unit = {}, + onDismiss: () -> Unit = {}, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, +) { + SimpleAlertDialogContent( + modifier = modifier, + title = title, + content = content, + submitText = retryText, + onSubmitClicked = onRetry, + cancelText = dismissText, + onCancelClicked = onDismiss, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + ) +} + +object RetryDialogDefaults { + val title: String @Composable get() = stringResource(id = CommonStrings.dialog_title_error) + val retryText: String @Composable get() = stringResource(id = CommonStrings.action_retry) + val dismissText: String @Composable get() = stringResource(id = CommonStrings.action_cancel) +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun RetryDialogPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + DialogPreview { + RetryDialogContent( + content = "Content", + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt new file mode 100644 index 0000000000..0de4dbba78 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.form + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember + +@Composable +fun textFieldState(stateValue: String): MutableState<String> = + remember(stateValue) { mutableStateOf(stateValue) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/keyboard/Keyboard.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/keyboard/Keyboard.kt new file mode 100644 index 0000000000..1ebb67f75a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/keyboard/Keyboard.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.keyboard + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle + +/** + * Inspired from https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose + */ +enum class Keyboard { + Opened, Closed +} + +// Note: it does not work as expected... +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun keyboardAsState(): State<Keyboard> { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val isResumed = lifecycle.currentState == Lifecycle.State.RESUMED + return rememberUpdatedState(if (WindowInsets.isImeVisible && isResumed) Keyboard.Opened else Keyboard.Closed) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt new file mode 100644 index 0000000000..be8c86448e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.ui.unit.dp + +internal val preferenceMinHeightOnlyTitle = 56.dp +internal val preferenceMinHeight = 56.dp +internal val preferencePaddingHorizontal = 16.dp diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt new file mode 100644 index 0000000000..4196edb0d5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Announcement +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PreferenceCategory( + modifier: Modifier = Modifier, + title: String? = null, + showDivider: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + ) { + if (title != null) { + PreferenceCategoryTitle(title = title) + } + content() + if (showDivider) { + PreferenceDivider() + } + } +} + +@Composable +fun PreferenceCategoryTitle(title: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier.padding( + top = 20.dp, + bottom = 8.dp, + start = preferencePaddingHorizontal, + end = preferencePaddingHorizontal, + ), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.materialColors.primary, + text = title, + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceCategoryPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceCategory( + title = "Category title", + ) { + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + ) + PreferenceSwitch( + title = "Switch", + icon = Icons.Default.Announcement, + isChecked = true + ) + PreferenceSlide( + title = "Slide", + summary = "Summary", + value = 0.75F, + showIconAreaIfNoIcon = true, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt new file mode 100644 index 0000000000..02c0e4eb05 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Announcement +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PreferenceCheckbox( + title: String, + isChecked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: ImageVector? = null, + showIconAreaIfNoIcon: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = preferenceMinHeight) + .clickable { onCheckedChange(!isChecked) } + .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), + verticalAlignment = Alignment.CenterVertically + ) { + PreferenceIcon( + icon = icon, + enabled = enabled, + isVisible = showIconAreaIfNoIcon + ) + Text( + modifier = Modifier + .weight(1f), + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = enabled.toEnabledColor(), + ) + Checkbox( + modifier = Modifier + .align(Alignment.CenterVertically), + checked = isChecked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceCheckboxPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceCheckbox( + title = "Checkbox", + icon = Icons.Default.Announcement, + enabled = true, + isChecked = true + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt new file mode 100644 index 0000000000..2e4b9e196b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PreferenceDivider( + modifier: Modifier = Modifier, +) { + Divider( + modifier = modifier, + color = ElementTheme.colors.borderDisabled, + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceDividerPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceDivider() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt new file mode 100644 index 0000000000..ef1c6ac09a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Simple Row with which follow design for preferences. + */ +@Composable +fun PreferenceRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier + .padding(horizontal = preferencePaddingHorizontal) + .heightIn(min = preferenceMinHeight) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceRowPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceRow { + Text(text = "Content") + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt new file mode 100644 index 0000000000..634d1d1db3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceScreen.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Announcement +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.theme.ElementTheme + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PreferenceView( + title: String, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + PreferenceTopAppBar( + title = title, + onBackPressed = onBackPressed, + ) + }, + snackbarHost = snackbarHost, + content = { + Column( + modifier = Modifier + .padding(it) + .consumeWindowInsets(it) + .verticalScroll(state = rememberScrollState()) + ) { + content() + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PreferenceTopAppBar( + title: String, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackPressed) + }, + title = { + Text( + text = title, + style = ElementTheme.typography.aliasScreenTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + ) +} + +@Preview +@Composable +internal fun PreferenceViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun PreferenceViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceView( + title = "Preference screen" + ) { + PreferenceCategory( + title = "Category title", + ) { + PreferenceText( + title = "Title", + subtitle = "Some other text", + icon = Icons.Default.BugReport, + ) + PreferenceDivider() + PreferenceSwitch( + title = "Switch", + icon = Icons.Default.Announcement, + isChecked = true, + ) + PreferenceDivider() + PreferenceCheckbox( + title = "Checkbox", + icon = Icons.Default.Announcement, + isChecked = true, + ) + PreferenceDivider() + PreferenceSlide( + title = "Slide", + summary = "Summary", + value = 0.75F, + showIconAreaIfNoIcon = true, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt new file mode 100644 index 0000000000..91a9852ed4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Slider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PreferenceSlide( + title: String, + @FloatRange(0.0, 1.0) + value: Float, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + showIconAreaIfNoIcon: Boolean = false, + enabled: Boolean = true, + summary: String? = null, + steps: Int = 0, + onValueChange: (Float) -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = preferenceMinHeight) + .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), + ) { + PreferenceIcon(icon = icon, isVisible = showIconAreaIfNoIcon) + Column( + modifier = Modifier + .weight(1f), + ) { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = enabled.toEnabledColor(), + ) + summary?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = summary, + color = enabled.toEnabledColor(), + ) + } + Slider( + value = value, + steps = steps, + onValueChange = onValueChange, + enabled = enabled, + ) + } + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceSlidePreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceSlide( + icon = Icons.Default.Person, + title = "Slide", + summary = "Summary", + value = 0.75F + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt new file mode 100644 index 0000000000..bbd3583688 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Announcement +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.toSecondaryEnabledColor +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun PreferenceSwitch( + title: String, + isChecked: Boolean, + modifier: Modifier = Modifier, + subtitle: String? = null, + enabled: Boolean = true, + icon: ImageVector? = null, + showIconAreaIfNoIcon: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, + switchAlignment: Alignment.Vertical = Alignment.CenterVertically +) { + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = preferenceMinHeight) + .clickable { onCheckedChange(!isChecked) } + .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), + verticalAlignment = Alignment.CenterVertically + ) { + PreferenceIcon( + icon = icon, + enabled = enabled, + isVisible = showIconAreaIfNoIcon + ) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = enabled.toEnabledColor(), + ) + if (subtitle != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + color = enabled.toSecondaryEnabledColor(), + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + // TODO Create a wrapper for Switch + Switch( + modifier = Modifier + .align(switchAlignment), + checked = isChecked, + enabled = enabled, + onCheckedChange = onCheckedChange + ) + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceSwitchPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = Icons.Default.Announcement, + enabled = true, + isChecked = true + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt new file mode 100644 index 0000000000..3f204ee847 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * Tried to use ListItem, but it cannot really match the design. Keep custom Layout for now. + */ +@Composable +fun PreferenceText( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + currentValue: String? = null, + loadingCurrentValue: Boolean = false, + icon: ImageVector? = null, + showIconAreaIfNoIcon: Boolean = false, + tintColor: Color? = null, + onClick: () -> Unit = {}, +) { + val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight + + Row( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = minHeight) + .clickable { onClick() } + .padding(horizontal = preferencePaddingHorizontal, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PreferenceIcon( + icon = icon, + isVisible = showIconAreaIfNoIcon, + tintColor = tintColor ?: ElementTheme.materialColors.secondary + ) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = tintColor ?: ElementTheme.materialColors.primary, + ) + if (subtitle != null) { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + color = tintColor ?: ElementTheme.materialColors.secondary, + ) + } + } + if (currentValue != null) { + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 16.dp, end = 8.dp), + text = currentValue, + style = ElementTheme.typography.fontBodyXsMedium, + color = ElementTheme.materialColors.secondary, + ) + } else if (loadingCurrentValue) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .padding(start = 16.dp, end = 8.dp) + .size(20.dp) + .align(Alignment.CenterVertically), + strokeWidth = 2.dp + ) + } + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + currentValue = "123", + ) + PreferenceText( + title = "Title", + subtitle = "Some content", + icon = Icons.Default.BugReport, + loadingCurrentValue = true, + ) + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + currentValue = "123", + ) + PreferenceText( + title = "Title", + icon = Icons.Default.BugReport, + loadingCurrentValue = true, + ) + PreferenceText( + title = "Title no icon with icon area", + showIconAreaIfNoIcon = true, + loadingCurrentValue = true, + ) + PreferenceText( + title = "Title no icon", + loadingCurrentValue = true, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/ImageVectorProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/ImageVectorProvider.kt new file mode 100644 index 0000000000..c59e8e8eb8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/ImageVectorProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class ImageVectorProvider : PreviewParameterProvider<ImageVector?> { + override val values: Sequence<ImageVector?> + get() = sequenceOf( + Icons.Default.BugReport, + null, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt new file mode 100644 index 0000000000..ec44a7846a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.components.preferences.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.toSecondaryEnabledColor + +@Composable +fun PreferenceIcon( + icon: ImageVector?, + modifier: Modifier = Modifier, + tintColor: Color? = null, + enabled: Boolean = true, + isVisible: Boolean = true, +) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = "", + tint = tintColor ?: enabled.toSecondaryEnabledColor(), + modifier = modifier + .padding(end = 16.dp) + .size(24.dp), + ) + } else if (isVisible) { + Spacer(modifier = modifier.width(40.dp)) + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceIconPreview(@PreviewParameter(ImageVectorProvider::class) content: ImageVector?) = + ElementThemedPreview { ContentToPreview(content) } + +@Composable +private fun ContentToPreview(content: ImageVector?) { + PreferenceIcon(content) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt new file mode 100644 index 0000000000..a18d0ef3ed --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.modifiers + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.debugInspectorInfo + +/** + * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. + */ +@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas +fun Modifier.applyIf( + condition: Boolean, + ifTrue: @Composable Modifier.() -> Modifier, + ifFalse: @Composable (Modifier.() -> Modifier)? = null +): Modifier = + composed( + inspectorInfo = debugInspectorInfo { + name = "applyIf" + value = condition + } + ) { + when { + condition -> then(ifTrue(Modifier)) + ifFalse != null -> then(ifFalse(Modifier)) + else -> this + } + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt new file mode 100644 index 0000000000..9675c54a20 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CircularReveal.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.modifiers + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.debugInspectorInfo +import kotlin.math.sqrt + +// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8 + +/** + * A modifier that clips the composable content using an animated circle. The circle will + * expand/shrink with an animation whenever [visible] changes. + * + * For more fine-grained control over the transition, see this method's overload, which allows passing + * a [State] object to control the progress of the reveal animation. + * + * By default, the circle is centered in the content, but custom positions may be specified using + * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/ +fun Modifier.circularReveal( + visible: Boolean, + showScrim: Boolean = false, + revealFrom: Offset = Offset(0.5f, 0.5f), +): Modifier = composed( + factory = { + val factor = updateTransition(visible, label = "Visibility") + .animateFloat(label = "revealFactor") { if (it) 1f else 0f } + + circularReveal(factor, showScrim, revealFrom) + }, + inspectorInfo = debugInspectorInfo { + name = "circularReveal" + properties["visible"] = visible + properties["revealFrom"] = revealFrom + } +) + +/** + * A modifier that clips the composable content using a circular shape. The radius of the circle + * will be determined by the [transitionProgress]. + * + * The values of the progress should be between 0 and 1. + * + * By default, the circle is centered in the content, but custom positions may be specified using + * [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom). + * */ +fun Modifier.circularReveal( + transitionProgress: State<Float>, + showScrim: Boolean = false, + revealFrom: Offset = Offset(0.5f, 0.5f) +): Modifier { + return drawWithCache { + val path = Path() + val center = revealFrom.mapTo(size) + val radius = calculateRadius(revealFrom, size) + val scrimColor = if (showScrim) + Color.Gray + else + Color.Transparent + + path.addOval(Rect(center, radius * transitionProgress.value)) + + onDrawWithContent { + if (showScrim) { + drawRect(scrimColor, alpha = transitionProgress.value * 0.75f) + } + clipPath(path) { this@onDrawWithContent.drawContent() } + } + } +} + +private fun Offset.mapTo(size: Size): Offset { + return Offset(x * size.width, y * size.height) +} + +private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) { + val x = (if (x > 0.5f) x else 1 - x) * size.width + val y = (if (y > 0.5f) y else 1 - y) * size.height + + sqrt(x * x + y * y) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt new file mode 100644 index 0000000000..e9531c3726 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * This modifier can be use to provide a nice background for Icon or ProgressIndicator. + */ +fun Modifier.roundedBackground( + size: Dp = 48.dp, + color: Color = Color.Black, + alpha: Float = 0.5f, +) = this + .size(size) + .clip(CircleShape) + .background(color = color.copy(alpha = alpha)) + .padding(8.dp) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt new file mode 100644 index 0000000000..201d6f7151 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.preview + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +/** + * Marker for a night mode preview. + * + * Previews with such marker will be rendered in night mode during screenshot testing. + * + * NB: Length of this constant is kept to a minimum to avoid screenshot file names being too long. + */ +const val NIGHT_MODE_NAME = "N" + +/** + * Marker for a day mode preview. + * + * This marker is currently not used during screenshot testing, it mainly act as a counterpart to [NIGHT_MODE_NAME]. + * + * NB: Length of this constant is kept to a minimum to avoid screenshot file names being too long. + */ +const val DAY_MODE_NAME = "D" + +/** + * Generates 2 previews of the composable it is applied to: day and night mode. + * + * NB: Content should be wrapped into [ElementPreview] to apply proper theming. + */ +@Preview(name = DAY_MODE_NAME) +@Preview(name = NIGHT_MODE_NAME, uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class DayNightPreviews diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt new file mode 100644 index 0000000000..3c2d61a76e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun ElementPreviewLight( + showBackground: Boolean = true, + content: @Composable () -> Unit +) { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + content = content + ) +} + +@Composable +fun ElementPreviewDark( + showBackground: Boolean = true, + content: @Composable () -> Unit +) { + ElementPreview( + darkTheme = true, + showBackground = showBackground, + content = content + ) +} + +@Composable +@Suppress("ModifierMissing") +fun ElementThemedPreview( + showBackground: Boolean = true, + vertical: Boolean = true, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .background(Color.Gray) + .padding(4.dp) + ) { + if (vertical) { + Column { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + content = content, + ) + Spacer(modifier = Modifier.height(4.dp)) + ElementPreview( + darkTheme = true, + showBackground = showBackground, + content = content + ) + } + } else { + Row { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + content = content, + ) + Spacer(modifier = Modifier.width(4.dp)) + ElementPreview( + darkTheme = true, + showBackground = showBackground, + content = content + ) + } + } + } +} + +@Composable +@Suppress("ModifierMissing") +fun ElementPreview( + darkTheme: Boolean = isSystemInDarkTheme(), + showBackground: Boolean = true, + content: @Composable () -> Unit +) { + ElementTheme(darkTheme = darkTheme) { + if (showBackground) { + // If we have a proper contentColor applied we need a Surface instead of a Box + Surface(content = content) + } else { + content() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/Images.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/Images.kt new file mode 100644 index 0000000000..4061c4486f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/Images.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.preview + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import io.element.android.libraries.designsystem.R + +/** + * I wanted to set up a FakeImageLoader as per https://github.com/coil-kt/coil/issues/1327 + * but it does not render in preview. In the meantime, you can use this trick to have image. + */ +@Composable +fun debugPlaceholder( + @DrawableRes debugPreview: Int, + nonDebugPainter: Painter? = null, +) = if (LocalInspectionMode.current) { + painterResource(id = debugPreview) +} else { + nonDebugPainter +} + +@Composable +fun debugPlaceholderBackground(nonDebugPainter: Painter? = null): Painter? { + return debugPlaceholder(debugPreview = R.drawable.sample_background, nonDebugPainter) +} + +@Composable +fun debugPlaceholderAvatar(nonDebugPainter: Painter? = null): Painter? { + return debugPlaceholder(debugPreview = R.drawable.sample_avatar, nonDebugPainter) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/LargeHeightPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/LargeHeightPreview.kt new file mode 100644 index 0000000000..7078c9a2c4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/LargeHeightPreview.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.preview + +import androidx.compose.ui.tooling.preview.Preview + +/** + * Our Paparazzi tests will check components with non-null `heightDp` and use a custom rendering for them, + * adding extra vertical space so long scrolling components can be displayed. This is a helper for that functionality. + */ +@Preview(heightDp = 1000) +annotation class LargeHeightPreview diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt new file mode 100644 index 0000000000..aaff9a49f9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.preview + +object PreviewGroup { + const val AppBars = "App Bars" + const val Avatars = "Avatars" + const val BottomSheets = "Bottom Sheets" + const val Buttons = "Buttons" + const val DateTimePickers = "DateTime pickers" + const val Dialogs = "Dialogs" + const val Dividers = "Dividers" + const val FABs = "Floating Action Buttons" + const val Icons = "Icons" + const val Menus = "Menus" + const val Preferences = "Preferences" + const val Progress = "Progress Indicators" + const val Search = "Search views" + const val Sliders = "Sliders" + const val Text = "Text" + const val TextFields = "TextFields" + const val Toggles = "Toggles" +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/HorizontalRuler.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/HorizontalRuler.kt new file mode 100644 index 0000000000..4bb5de7cb4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/HorizontalRuler.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.ruler + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +/** + * Horizontal ruler is a debug composable that displays a horizontal ruler. + * It can be used to display the horizontal ruler in the composable preview. + */ +@Composable +fun HorizontalRuler( + modifier: Modifier = Modifier, +) { + val baseColor = Color.Magenta + val alphaBaseColor = baseColor.copy(alpha = 0.2f) + Row(modifier = modifier.fillMaxWidth()) { + repeat(50) { + HorizontalRulerItem(1.dp, alphaBaseColor) + HorizontalRulerItem(2.dp, baseColor) + HorizontalRulerItem(1.dp, alphaBaseColor) + HorizontalRulerItem(2.dp, baseColor) + HorizontalRulerItem(5.dp, alphaBaseColor) + HorizontalRulerItem(2.dp, baseColor) + HorizontalRulerItem(1.dp, alphaBaseColor) + HorizontalRulerItem(2.dp, baseColor) + HorizontalRulerItem(1.dp, alphaBaseColor) + HorizontalRulerItem(10.dp, baseColor) + } + } +} + +@Composable +private fun HorizontalRulerItem(height: Dp, color: Color) { + Spacer( + modifier = Modifier + .size(height = height, width = 1.dp) + .background(color = color) + ) +} + +@Preview +@Composable +internal fun HorizontalRulerLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun HorizontalRulerDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + HorizontalRuler() +} + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/VerticalRuler.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/VerticalRuler.kt new file mode 100644 index 0000000000..3557812fcd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/VerticalRuler.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.ruler + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +/** + * Vertical ruler is a debug composable that displays a vertical ruler. + * It can be used to display the vertical ruler in the composable preview. + */ +@Composable +fun VerticalRuler( + modifier: Modifier = Modifier, +) { + val baseColor = Color.Red + val alphaBaseColor = baseColor.copy(alpha = 0.2f) + Column(modifier = modifier.fillMaxHeight()) { + repeat(50) { + VerticalRulerItem(1.dp, alphaBaseColor) + VerticalRulerItem(2.dp, baseColor) + VerticalRulerItem(1.dp, alphaBaseColor) + VerticalRulerItem(2.dp, baseColor) + VerticalRulerItem(5.dp, alphaBaseColor) + VerticalRulerItem(2.dp, baseColor) + VerticalRulerItem(1.dp, alphaBaseColor) + VerticalRulerItem(2.dp, baseColor) + VerticalRulerItem(1.dp, alphaBaseColor) + VerticalRulerItem(10.dp, baseColor) + } + } +} + +@Composable +private fun VerticalRulerItem(width: Dp, color: Color) { + Spacer( + modifier = Modifier + .size(height = 1.dp, width = width) + .background(color = color) + ) +} + +@Preview +@Composable +internal fun VerticalRulerLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun VerticalRulerDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + VerticalRuler() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt new file mode 100644 index 0000000000..a5fc895e5d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.ruler + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Debug tool to add a vertical and a horizontal ruler on top of the content. + */ +@Composable +fun WithRulers( + modifier: Modifier = Modifier, + xRulersOffset: Dp = 0.dp, + yRulersOffset: Dp = 0.dp, + content: @Composable () -> Unit +) { + Layout( + modifier = modifier, + content = { + content() + VerticalRuler() + HorizontalRuler() + }, + measurePolicy = { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + // Use layout size of the first item (the content) + layout( + width = placeables.first().width, + height = placeables.first().height + ) { + placeables.forEachIndexed { index, placeable -> + if (index == 0) { + placeable.place(0, 0) + } else { + placeable.place(xRulersOffset.roundToPx(), yRulersOffset.roundToPx()) + } + } + } + } + ) +} + +@Preview +@Composable +internal fun WithRulerLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun WithRulerDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + WithRulers(xRulersOffset = 20.dp, yRulersOffset = 15.dp) { + OutlinedButton(onClick = {}) { + Text(text = "A Button with rulers on it!") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt new file mode 100644 index 0000000000..4f613248a6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.showkase + +import com.airbnb.android.showkase.annotation.ShowkaseRoot +import com.airbnb.android.showkase.annotation.ShowkaseRootModule + +@ShowkaseRoot +class DesignSystemShowkaseRootModule : ShowkaseRootModule diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt new file mode 100644 index 0000000000..77d80cdde5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.swipe + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Inspired from https://github.com/bmarty/swipe/blob/trunk/swipe/src/main/kotlin/me/saket/swipe/SwipeableActionsState.kt + */ +@Composable +fun rememberSwipeableActionsState(): SwipeableActionsState { + return remember { SwipeableActionsState() } +} + +@Stable +class SwipeableActionsState { + /** + * The current position (in pixels) of the content. + */ + val offset: State<Float> get() = offsetState + private var offsetState = mutableStateOf(0f) + + /** + * Whether the content is currently animating to reset its offset after it was swiped. + */ + var isResettingOnRelease: Boolean by mutableStateOf(false) + private set + + val draggableState = DraggableState { delta -> + val targetOffset = offsetState.value + delta + val isAllowed = isResettingOnRelease || targetOffset > 0f + + offsetState.value += if (isAllowed) delta else 0f + } + + suspend fun resetOffset() { + draggableState.drag(MutatePriority.PreventUserInput) { + isResettingOnRelease = true + try { + Animatable(offsetState.value).animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 300), + ) { + dragBy(value - offsetState.value) + } + } finally { + isResettingOnRelease = false + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt new file mode 100644 index 0000000000..6b16ff96e7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.text + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import io.element.android.libraries.theme.LinkColor + +fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + append(this@toAnnotatedString) + val spannable = SpannableString(this@toAnnotatedString) + spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) + } + is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) + } + } +} + +/** + * Convert a string to an [AnnotatedString] with styles applied. + * + * @param fullTextRes the string resource to use as the full text. Must contain a single %s + * @param coloredTextRes the string resource to use as the colored part of the string + * @param color the color to apply to the string + * @param underline whether to underline the string + * @param bold whether to bold the string + */ +@Composable +fun buildAnnotatedStringWithStyledPart( + @StringRes fullTextRes: Int, + @StringRes coloredTextRes: Int, + color: Color = LinkColor, + underline: Boolean = true, + bold: Boolean = false, +) = buildAnnotatedString { + val coloredPart = stringResource(coloredTextRes) + val fullText = stringResource(fullTextRes, coloredPart) + val startIndex = fullText.indexOf(coloredPart) + append(fullText) + addStyle( + style = SpanStyle( + color = color, + textDecoration = if (underline) TextDecoration.Underline else null, + fontWeight = if (bold) FontWeight.Bold else null, + ), + start = startIndex, + end = startIndex + coloredPart.length, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/UnitConverters.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/UnitConverters.kt new file mode 100644 index 0000000000..1ee7d0d603 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/UnitConverters.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.text + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit + +/** + * Convert Dp to Sp, regarding current density. + * Can be used for instance to use Dp unit for text. + */ +@Composable +fun Dp.toSp(): TextUnit = with(LocalDensity.current) { toSp() } + +/** + * Convert Sp to Dp, regarding current density. + * Can be used for instance to use Sp unit for size. + */ +@Composable +fun TextUnit.toDp(): Dp = with(LocalDensity.current) { toDp() } + +/** + * Convert Px value to Dp, regarding current density. + */ +@Composable +fun Int.toDp(): Dp = with(LocalDensity.current) { toDp() } + +/** + * Convert Dp value to pixels, regarding current density. + */ +@Composable +fun Dp.toPx(): Float = with(LocalDensity.current) { toPx() } + +/** + * Convert Dp value to pixels, regarding current density. + */ +@Composable +fun Dp.roundToPx(): Int = with(LocalDensity.current) { roundToPx() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt new file mode 100644 index 0000000000..b9e0893836 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.theme.compound.generated.SemanticColors +import io.element.android.libraries.theme.previews.ColorListPreview +import kotlinx.collections.immutable.persistentMapOf + +/** + * Room list. + */ +@Composable +fun MaterialTheme.roomListRoomName() = colorScheme.primary + +@Composable +fun MaterialTheme.roomListRoomMessage() = colorScheme.secondary + +@Composable +fun MaterialTheme.roomListRoomMessageDate() = colorScheme.secondary + +val SemanticColors.unreadIndicator + get() = iconAccentTertiary + +val SemanticColors.placeholderBackground + get() = bgSubtleSecondary + +// This color is not present in Semantic color, so put hard-coded value for now +val SemanticColors.messageFromMeBackground + get() = if (isLight) { + // We want LightDesignTokens.colorGray400 + Color(0xFFE1E6EC) + } else { + // We want DarkDesignTokens.colorGray500 + Color(0xFF323539) + } + +// This color is not present in Semantic color, so put hard-coded value for now +val SemanticColors.messageFromOtherBackground + get() = if (isLight) { + // We want LightDesignTokens.colorGray300 + Color(0xFFF0F2F5) + } else { + // We want DarkDesignTokens.colorGray400 + Color(0xFF26282D) + } + +// Temporary color, which is not in the token right now +val SemanticColors.temporaryColorBgSpecial + get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048) + +@Preview +@Composable +internal fun ColorAliasesLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun ColorAliasesDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + ColorListPreview( + backgroundColor = Color.Black, + foregroundColor = Color.White, + colors = persistentMapOf( + "roomListRoomName" to MaterialTheme.roomListRoomName(), + "roomListRoomMessage" to MaterialTheme.roomListRoomMessage(), + "roomListRoomMessageDate" to MaterialTheme.roomListRoomMessageDate(), + "unreadIndicator" to ElementTheme.colors.unreadIndicator, + "placeholderBackground" to ElementTheme.colors.placeholderBackground, + "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, + "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, + "temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial, + ) + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTypography.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTypography.kt new file mode 100644 index 0000000000..f7ca9885ce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTypography.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle + +// Temporary style for text that needs to be aligned without weird font padding issues. `includeFontPadding` will default to false in a future version of +// compose, at which point this can be removed. +// +// Ref: https://medium.com/androiddevelopers/fixing-font-padding-in-compose-text-768cd232425b +@Suppress("DEPRECATION") +val noFontPadding: TextStyle = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false + ) +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/TypographyAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/TypographyAliases.kt new file mode 100644 index 0000000000..f5aba325d8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/TypographyAliases.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme + +import androidx.compose.ui.text.TextStyle +import io.element.android.libraries.theme.compound.generated.TypographyTokens + +/* + * This file contains aliases for TypographyTokens. + */ + +val TypographyTokens.aliasScreenTitle: TextStyle + get() = fontHeadingSmMedium + +val TypographyTokens.aliasButtonText: TextStyle + get() = fontBodyLgMedium diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt new file mode 100644 index 0000000000..6d3487b3a2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp + +@Composable +@ExperimentalMaterial3Api +fun BottomSheetScaffold( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), + sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, + sheetShape: Shape = BottomSheetDefaults.ExpandedShape, + sheetContainerColor: Color = BottomSheetDefaults.ContainerColor, + sheetContentColor: Color = contentColorFor(sheetContainerColor), + sheetTonalElevation: Dp = BottomSheetDefaults.Elevation, + sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, + sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + sheetSwipeEnabled: Boolean = true, + topBar: @Composable (() -> Unit)? = null, + snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, + containerColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(containerColor), + content: @Composable (PaddingValues) -> Unit +) { + androidx.compose.material3.BottomSheetScaffold( + sheetContent = sheetContent, + modifier = modifier, + scaffoldState = scaffoldState, + sheetPeekHeight = sheetPeekHeight, + sheetShape = sheetShape, + sheetContainerColor = sheetContainerColor, + sheetContentColor = sheetContentColor, + sheetTonalElevation = sheetTonalElevation, + sheetShadowElevation = sheetShadowElevation, + sheetDragHandle = sheetDragHandle, + sheetSwipeEnabled = sheetSwipeEnabled, + topBar = topBar, + snackbarHost = snackbarHost, + containerColor = containerColor, + contentColor = contentColor, + content = content + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt new file mode 100644 index 0000000000..64c79a3906 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ElementButtonDefaults.shape, + colors: ButtonColors = ElementButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content, + ) +} + +object ElementButtonDefaults { + val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) + val shape: Shape @Composable get() = ButtonDefaults.shape + @Composable + fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation() + + @Composable + fun buttonColors(): ButtonColors = ButtonDefaults.buttonColors() + +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun ButtonPreview() = ElementThemedPreview { + Column { + Button(onClick = {}, enabled = true) { + Text(text = "Click me! - Enabled") + } + Button(onClick = {}, enabled = false) { + Text(text = "Click me! - Disabled") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt new file mode 100644 index 0000000000..b754b3d420 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun Checkbox( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: CheckboxColors = CheckboxDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + androidx.compose.material3.Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun CheckboxesPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + Checkbox(onCheckedChange = {}, enabled = true, checked = true) + Checkbox(onCheckedChange = {}, enabled = true, checked = false) + Checkbox(onCheckedChange = {}, enabled = false, checked = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = false) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt new file mode 100644 index 0000000000..392e267c77 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun CircularProgressIndicator( + progress: Float, + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, + strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth +) { + androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + progress = progress, + color = color, + strokeWidth = strokeWidth, + ) +} + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, + strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth, +) { + if (LocalInspectionMode.current) { + // Use a determinate progress indicator to improve the preview rendering + androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + progress = 0.75F, + color = color, + strokeWidth = strokeWidth, + ) + } else { + androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = color, + strokeWidth = strokeWidth, + ) + } +} + +@Preview(group = PreviewGroup.Progress) +@Composable +internal fun CircularProgressIndicatorPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Indeterminate progress + CircularProgressIndicator( + ) + // Fixed progress + CircularProgressIndicator( + progress = 0.90F + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt new file mode 100644 index 0000000000..a8d64d271d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DividerDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun Divider( + modifier: Modifier = Modifier, + thickness: Dp = ElementDividerDefaults.thickness, + color: Color = DividerDefaults.color, +) { + androidx.compose.material3.Divider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} + +object ElementDividerDefaults { + val thickness = 0.5.dp +} + +@Preview(group = PreviewGroup.Dividers) +@Composable +internal fun DividerPreview() = ElementThemedPreview { + Box(Modifier.padding(vertical = 10.dp), contentAlignment = Alignment.Center) { + ContentToPreview() + } +} + +@Composable +private fun ContentToPreview() { + Divider() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt new file mode 100644 index 0000000000..175c0fb402 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import io.element.android.libraries.theme.ElementTheme + +private val minMenuWidth = 200.dp + +@Composable +fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + // By default add a 16.dp offset to the menu + offset: DpOffset = DpOffset(x = 16.dp, y = 0.dp), + properties: PopupProperties = PopupProperties(focusable = true), + content: @Composable ColumnScope.() -> Unit +) { + val bgColor = if (ElementTheme.isLightTheme) { + ElementTheme.materialColors.background + } else { + ElementTheme.colors.bgSubtlePrimary + } + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier + .background(color = bgColor) + .widthIn(min = minMenuWidth), + offset = offset, + properties = properties, + content = content + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt new file mode 100644 index 0000000000..b8c18f99c6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun DropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + colors: MenuItemColors = MenuDefaults.itemColors(), + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + androidx.compose.material3.DropdownMenuItem( + text = text, + onClick = onClick, + modifier = modifier, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + colors = colors, + contentPadding = contentPadding, + interactionSource = interactionSource + ) +} + +@Composable +fun DropdownMenuItemText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + color = ElementTheme.materialColors.primary, + style = ElementTheme.typography.fontBodyLgRegular, + modifier = modifier, + ) +} + +@Preview(group = PreviewGroup.Menus) +@Composable +internal fun DropdownMenuItemPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + DropdownMenuItem( + text = { DropdownMenuItemText(text = "Item") }, + onClick = {}, + leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }, + trailingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt new file mode 100644 index 0000000000..e126a429d8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun FloatingActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = FloatingActionButtonDefaults.shape, + containerColor: Color = FloatingActionButtonDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + androidx.compose.material3.FloatingActionButton( + onClick = onClick, + modifier = modifier, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.FABs) +@Composable +internal fun FloatingActionButtonPreview() = + ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Box(modifier = Modifier.padding(8.dp)) { + FloatingActionButton(onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt new file mode 100644 index 0000000000..24de433058 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.annotation.DrawableRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +/** + * Icon is a wrapper around [androidx.compose.material3.Icon] which allows to use + * [ImageVector], [ImageBitmap] or [DrawableRes] as icon source. + * + * @param contentDescription the content description to be used for accessibility + * @param modifier the modifier to apply to this layout + * @param tint the tint to apply to the icon + * @param imageVector the image vector of the icon to display, exclusive with [bitmap] and [resourceId] + * @param bitmap the bitmap of the icon to display, exclusive with [imageVector] and [resourceId] + * @param resourceId the resource id of the icon to display, exclusive with [imageVector] and [bitmap] + */ +@Composable +fun Icon( + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + imageVector: ImageVector? = null, + bitmap: ImageBitmap? = null, + @DrawableRes resourceId: Int? = null, +) { + when { + imageVector != null -> { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + bitmap != null -> { + Icon( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + resourceId != null -> { + Icon( + resourceId = resourceId, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) + } + } +} + +@Composable +fun Icon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) { + androidx.compose.material3.Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun Icon( + bitmap: ImageBitmap, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) { + androidx.compose.material3.Icon( + bitmap = bitmap, + contentDescription = contentDescription, + modifier = modifier, + tint = tint, + ) +} + +@Composable +fun Icon( + @DrawableRes resourceId: Int, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + androidx.compose.material3.Icon( + painter = painterResource(id = resourceId), + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) +} + +@Preview(group = PreviewGroup.Icons) +@Composable +internal fun IconImageVectorPreview() = + ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt new file mode 100644 index 0000000000..14cc6b62ce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + androidx.compose.material3.IconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors(), + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun IconButtonPreview() = + ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Row { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + IconButton(enabled = false, onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt new file mode 100644 index 0000000000..d3cd2fee07 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediumTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + androidx.compose.material3.MediumTopAppBar( + title = title, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} + +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun MediumTopAppBarPreview() = + ElementThemedPreview { ContentToPreview() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview() { + MediumTopAppBar(title = { Text(text = "Title") }) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt new file mode 100644 index 0000000000..a31e4188c6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = if (ElementTheme.isLightTheme) 0.dp else BottomSheetDefaults.Elevation, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, + content: @Composable ColumnScope.() -> Unit, +) { + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + sheetState = sheetState, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + scrimColor = scrimColor, + dragHandle = dragHandle, + windowInsets = windowInsets, + content = content, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +fun SheetState.hide(coroutineScope: CoroutineScope, then: suspend () -> Unit) { + coroutineScope.launch { + hide() + then() + } +} + +// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetLightPreview() = + ElementPreviewLight { ContentToPreview() } + +// This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview() { + Box( + modifier = Modifier.fillMaxSize(), + ) { + ModalBottomSheet( + onDismissRequest = {}, + sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + ), + ) { + Text( + text = "Sheet Content", + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp) + .background(color = Color.Green) + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt new file mode 100644 index 0000000000..2f40a90615 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetDefaults +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.modifiers.applyIf +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ModalBottomSheetLayout( + sheetContent: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), + sheetShape: Shape = MaterialTheme.shapes.large.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)), + sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, + sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, + sheetContentColor: Color = contentColorFor(sheetBackgroundColor), + scrimColor: Color = ModalBottomSheetDefaults.scrimColor, + displayHandle: Boolean = false, + useSystemPadding: Boolean = true, + content: @Composable () -> Unit = {} +) { + androidx.compose.material.ModalBottomSheetLayout( + sheetContent = { + Column( + Modifier.fillMaxWidth() + .applyIf(useSystemPadding, ifTrue = { + navigationBarsPadding() + }) + ) { + if (displayHandle) { + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp)) + .size(width = 32.dp, height = 4.dp) + .align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(24.dp)) + } + sheetContent() + } + }, + modifier = modifier, + sheetState = sheetState, + sheetShape = sheetShape, + sheetElevation = sheetElevation, + sheetBackgroundColor = sheetBackgroundColor, + sheetContentColor = sheetContentColor, + scrimColor = scrimColor, + content = content, + ) +} + +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetLayoutLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.BottomSheets) +@Composable +internal fun ModalBottomSheetLayoutDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun ContentToPreview() { + ModalBottomSheetLayout( + modifier = Modifier.height(140.dp), + displayHandle = true, + sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded), + sheetContent = { + Text(text = "Sheet Content", modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 20.dp) + .background(color = Color.Green)) + } + ) { + Text(text = "Content", modifier = Modifier.background(color = Color.Red)) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt new file mode 100644 index 0000000000..fa7ee261f6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun OutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ElementOutlinedButtonDefaults.shape, + colors: ButtonColors = ElementOutlinedButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ElementOutlinedButtonDefaults.buttonElevation(), + border: BorderStroke? = ElementOutlinedButtonDefaults.border, + contentPadding: PaddingValues = ElementOutlinedButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content, + ) +} + +object ElementOutlinedButtonDefaults { + val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) + val shape: Shape @Composable get() = ButtonDefaults.outlinedShape + val border: BorderStroke @Composable get() = ButtonDefaults.outlinedButtonBorder + @Composable + fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation() + + @Composable + fun buttonColors(): ButtonColors = ButtonDefaults.outlinedButtonColors() + + +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonsPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = {}, enabled = true) { + Text(text = "Click me! - Enabled") + } + OutlinedButton(onClick = {}, enabled = false) { + Text(text = "Click me! - Disabled") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt new file mode 100644 index 0000000000..2c7318c447 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedTextField.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.utils.allBooleans +import io.element.android.libraries.designsystem.utils.asInt + +@Composable +fun OutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) { + androidx.compose.material3.OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + +@Composable +fun OutlinedTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = Int.MAX_VALUE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) { + androidx.compose.material3.OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.onTabOrEnterKeyFocusNext(focusManager: FocusManager): Modifier = onPreviewKeyEvent { event -> + if (event.key == Key.Tab || event.key == Key.Enter) { + if (event.type == KeyEventType.KeyUp) { + focusManager.moveFocus(FocusDirection.Down) + } + true + } else { + false + } +} + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun OutlinedTextFieldsPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun OutlinedTextFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + allBooleans.forEach { isError -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + OutlinedTextField( + onValueChange = {}, + label = { Text(text = "label") }, + value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + isError = isError, + enabled = enabled, + readOnly = readonly, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt new file mode 100644 index 0000000000..6b0c1b377e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.RadioButtonColors +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun RadioButton( + selected: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: RadioButtonColors = RadioButtonDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + androidx.compose.material3.RadioButton( + selected = selected, + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + RadioButton(selected = false, onClick = {}) + RadioButton(selected = true, onClick = {}) + RadioButton(selected = false, enabled = false, onClick = {}) + RadioButton(selected = true, enabled = false, onClick = {}) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Scaffold.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Scaffold.kt new file mode 100644 index 0000000000..20e30e55b3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Scaffold.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (PaddingValues) -> Unit +) { + androidx.compose.material3.Scaffold( + modifier = modifier, + topBar = topBar, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + content = content, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt new file mode 100644 index 0000000000..c129499a56 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun <T> SearchBar( + query: String, + onQueryChange: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + placeHolderTitle: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + showBackButton: Boolean = true, + resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(), + shape: Shape = SearchBarDefaults.inputFieldShape, + tonalElevation: Dp = SearchBarDefaults.Elevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + inactiveColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(), + activeColors: SearchBarColors = ElementSearchBarDefaults.activeColors(), + contentPrefix: @Composable ColumnScope.() -> Unit = {}, + contentSuffix: @Composable ColumnScope.() -> Unit = {}, + resultHandler: @Composable ColumnScope.(T) -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + + if (!active) { + onQueryChange("") + focusManager.clearFocus() + } + + androidx.compose.material3.SearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + active = active, + onActiveChange = onActiveChange, + modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp), + enabled = enabled, + placeholder = { + Text(text = placeHolderTitle) + }, + leadingIcon = if (showBackButton && active) { + { BackButton(onClick = { onActiveChange(false) }) } + } else { + null + }, + trailingIcon = when { + active && query.isNotEmpty() -> { + { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(CommonStrings.action_clear), + ) + } + } + } + + !active -> { + { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(CommonStrings.action_search), + tint = MaterialTheme.colorScheme.tertiary, + ) + } + } + + else -> null + }, + shape = shape, + colors = if (active) activeColors else inactiveColors, + tonalElevation = tonalElevation, + windowInsets = windowInsets, + interactionSource = interactionSource, + content = { + contentPrefix() + when (resultState) { + is SearchBarResultState.Results<T> -> { + resultHandler(resultState.results) + } + + is SearchBarResultState.NoResults<T> -> { + // No results found, show a message + Spacer(Modifier.size(80.dp)) + + Text( + text = stringResource(CommonStrings.common_no_results), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } + + else -> { + // Not searching - nothing to show. + } + } + contentSuffix() + }, + ) +} + +object ElementSearchBarDefaults { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun inactiveColors() = SearchBarDefaults.colors( + containerColor = ElementTheme.materialColors.surfaceVariant, + inputFieldColors = TextFieldDefaults.colors( + unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, + focusedPlaceholderColor = ElementTheme.colors.textDisabled, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.primary, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary, + focusedTrailingIconColor = MaterialTheme.colorScheme.primary, + ) + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun activeColors() = SearchBarDefaults.colors( + containerColor = Color.Transparent, + inputFieldColors = TextFieldDefaults.colors( + unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, + focusedPlaceholderColor = ElementTheme.colors.textDisabled, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.primary, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.primary, + focusedTrailingIconColor = MaterialTheme.colorScheme.primary, + ) + ) +} + +sealed interface SearchBarResultState<in T> { + /** No search results are available yet (e.g. because the user hasn't entered a search term). */ + class NotSearching<T> : SearchBarResultState<T> + + /** The search has completed, but no results were found. */ + class NoResults<T> : SearchBarResultState<T> + + /** The search has completed, and some matching users were found. */ + data class Results<T>(val results: T) : SearchBarResultState<T> +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewInactive() = ElementThemedPreview { ContentToPreview() } + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewActiveEmptyQuery() = ElementThemedPreview { + ContentToPreview( + query = "", + active = true, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewActiveWithQueryNoBackButton() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + showBackButton = false, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + resultState = SearchBarResultState.NoResults(), + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + resultState = SearchBarResultState.Results("result!"), + contentPrefix = { + Text( + text = "Content that goes before the search results", + modifier = Modifier + .background(color = Color.Red) + .fillMaxWidth() + ) + }, + contentSuffix = { + Text( + text = "Content that goes after the search results", + modifier = Modifier + .background(color = Color.Blue) + .fillMaxWidth() + ) + }, + resultHandler = { + Text( + text = "Results go here", + modifier = Modifier + .background(color = Color.Green) + .fillMaxWidth() + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview( + query: String = "", + active: Boolean = false, + showBackButton: Boolean = true, + resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(), + contentPrefix: @Composable ColumnScope.() -> Unit = {}, + contentSuffix: @Composable ColumnScope.() -> Unit = {}, + resultHandler: @Composable ColumnScope.(String) -> Unit = {}, +) { + SearchBar( + query = query, + active = active, + resultState = resultState, + showBackButton = showBackButton, + onQueryChange = {}, + onActiveChange = {}, + placeHolderTitle = "Search for things", + contentPrefix = contentPrefix, + contentSuffix = contentSuffix, + resultHandler = resultHandler, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt new file mode 100644 index 0000000000..872376583d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun Slider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange<Float> = 0f..1f, + /*@IntRange(from = 0)*/ + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + androidx.compose.material3.Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview(group = PreviewGroup.Sliders) +@Composable +internal fun SlidersPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + var value by remember { mutableStateOf(0.33f) } + Column { + Slider(onValueChange = { value = it }, value = value, enabled = true) + Slider(steps = 10, onValueChange = { value = it }, value = value, enabled = true) + Slider(onValueChange = { value = it }, value = value, enabled = false) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt new file mode 100644 index 0000000000..db7ab7fc08 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview + +@Composable +fun Surface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + color: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(color), + tonalElevation: Dp = 0.dp, + shadowElevation: Dp = 0.dp, + border: BorderStroke? = null, + content: @Composable () -> Unit +) { + androidx.compose.material3.Surface( + modifier = modifier, + shape = shape, + color = color, + contentColor = contentColor, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + border = border, + content = content, + ) +} + +@Preview +@Composable +internal fun SurfacePreview() = + ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Surface { + Spacer(modifier = Modifier.size(64.dp)) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt new file mode 100644 index 0000000000..c9b9fb65db --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.utils.toHrf +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +@Composable +fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + androidx.compose.material3.Text( + text = text, + modifier = modifier, + color = color, + fontStyle = fontStyle, + textDecoration = textDecoration, + textAlign = textAlign, + overflow = overflow, + softWrap = softWrap, + minLines = minLines, + maxLines = maxLines, + onTextLayout = onTextLayout, + style = style, + ) +} + +@Composable +fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + // Will be removed, only style should be used + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + // Will be removed, only style should be used + fontWeight: FontWeight? = null, + // Will be removed, only style should be used + fontFamily: FontFamily? = null, + // Will be removed, only style should be used + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + // Will be removed, only style should be used + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + androidx.compose.material3.Text( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + minLines = minLines, + maxLines = maxLines, + inlineContent = inlineContent, + onTextLayout = onTextLayout, + style = style, + ) +} + +@Preview(group = PreviewGroup.Text) +@Composable +internal fun TextLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.Text) +@Composable +internal fun TextDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + val colors = mapOf( + "primary" to MaterialTheme.colorScheme.primary, + "secondary" to MaterialTheme.colorScheme.secondary, + "tertiary" to MaterialTheme.colorScheme.tertiary, + "background" to MaterialTheme.colorScheme.background, + "error" to MaterialTheme.colorScheme.error, + "surface" to MaterialTheme.colorScheme.surface, + "surfaceVariant" to MaterialTheme.colorScheme.surfaceVariant, + "primaryContainer" to MaterialTheme.colorScheme.primaryContainer, + "secondaryContainer" to MaterialTheme.colorScheme.secondaryContainer, + "tertiaryContainer" to MaterialTheme.colorScheme.tertiaryContainer, + // "inversePrimary" to MaterialTheme.colorScheme.inversePrimary, + "errorContainer" to MaterialTheme.colorScheme.errorContainer, + "inverseSurface" to MaterialTheme.colorScheme.inverseSurface, + ) + Column( + modifier = Modifier.width(IntrinsicSize.Max) + ) { + colors.keys.forEach { name -> + val color = colors[name]!! + val textColor = contentColorFor(backgroundColor = color) + Box( + modifier = Modifier + .background(color = color) + .fillMaxWidth() + .padding(2.dp) + ) { + Text( + text = "Text on $name\n${textColor.toHrf()} on ${color.toHrf()}", + color = textColor, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt new file mode 100644 index 0000000000..3b4b50a5e7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun TextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.textShape, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + elevation: ButtonElevation? = null, + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonPreview() = ElementThemedPreview { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + TextButton(onClick = {}, enabled = true) { + Text(text = "Click me! - Enabled") + } + TextButton(onClick = {}, enabled = false) { + Text(text = "Click me! - Disabled") + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt new file mode 100644 index 0000000000..2ad41887aa --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.utils.allBooleans +import io.element.android.libraries.designsystem.utils.asInt + +@Composable +fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + androidx.compose.material3.TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + +@Composable +fun TextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = Int.MAX_VALUE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + androidx.compose.material3.TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + maxLines = maxLines, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun TextFieldLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun TextFieldDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + allBooleans.forEach { isError -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + TextField( + onValueChange = {}, + label = { Text(text = "label") }, + value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + isError = isError, + enabled = enabled, + readOnly = readonly, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.autofill(autofillTypes: List<AutofillType>, onFill: (String) -> Unit) = composed { + val autofillNode = AutofillNode(autofillTypes, onFill = onFill) + LocalAutofillTree.current += autofillNode + + val autofill = LocalAutofill.current + + this + .onGloballyPositioned { + // Inform autofill framework of where our composable is so it can show the popup in the right place + autofillNode.boundingBox = it.boundsInWindow() + } + .onFocusChanged { + autofill?.run { + if (it.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt new file mode 100644 index 0000000000..23848ef76d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + androidx.compose.material3.TopAppBar( + title = title, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} + +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun TopAppBarPreview() = + ElementThemedPreview { ContentToPreview() } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview() { + TopAppBar(title = { Text(text = "Title") }) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt new file mode 100644 index 0000000000..a422c5e729 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components.previews + +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.dialogs.AlertDialogContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun DatePickerPreviewLight() { + ElementPreviewLight { ContentToPreview() } +} + +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun DatePickerPreviewDark() { + ElementPreviewDark { ContentToPreview() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContentToPreview() { + val state = rememberDatePickerState( + initialSelectedDateMillis = 1672578000000L, + ) + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + text = { DatePicker(state = state, showModeToggle = true) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt new file mode 100644 index 0000000000..f1c2cd444d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components.previews + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowRight +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.DropdownMenu +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Preview(group = PreviewGroup.Menus) +@Composable +internal fun MenuPreview() { + ElementThemedPreview { + var isExpanded by remember { mutableStateOf(false) } + Button(onClick = { isExpanded = !isExpanded }) { + Text("Toggle") + } + DropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) { + for (i in 0..5) { + val leadingIcon: @Composable (() -> Unit)? = if (i in 2..3) { + @Composable { + Icon(Icons.Filled.Favorite, contentDescription = "Favorite") + } + } else { + null + } + + val trailingIcon: @Composable (() -> Unit)? = if (i in 3..4) { + @Composable { + Icon(Icons.Filled.ArrowRight, contentDescription = "Favorite") + } + } else { + null + } + DropdownMenuItem( + text = { DropdownMenuItemText(text = "Item $i") }, + onClick = { isExpanded = false }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt new file mode 100644 index 0000000000..11491a0a1c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components.previews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Icon + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun SwitchPreview() { + ElementThemedPreview { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + var checked by remember { mutableStateOf(false) } + Switch(checked = checked, onCheckedChange = { checked = !checked }) + Switch(checked = checked, onCheckedChange = { checked = !checked }, thumbContent = { + Icon(imageVector = Icons.Outlined.Check, contentDescription = null) + }) + Switch(checked = checked, enabled = false, onCheckedChange = { checked = !checked }) + Switch(checked = checked, enabled = false, onCheckedChange = { checked = !checked }, thumbContent = { + Icon(imageVector = Icons.Outlined.Check, contentDescription = null) + }) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt new file mode 100644 index 0000000000..79f0fffbee --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.theme.components.previews + +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerLayoutType +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.dialogs.AlertDialogContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(widthDp = 600, group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerHorizontalPreview() { + ElementThemedPreview { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + text = { TimePicker(state = rememberTimePickerState(), layoutType = TimePickerLayoutType.Horizontal) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerVerticalPreviewLight() { + ElementPreviewLight { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + text = { TimePicker(state = rememberTimePickerState(), layoutType = TimePickerLayoutType.Vertical) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerVerticalPreviewDark() { + val pickerState = rememberTimePickerState( + initialHour = 12, + initialMinute = 0, + ) + ElementPreviewDark { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + text = { TimePicker(state = pickerState, layoutType = TimePickerLayoutType.Vertical) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = MaterialTheme.colorScheme.primary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/BooleanProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/BooleanProvider.kt new file mode 100644 index 0000000000..43d0f9e797 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/BooleanProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class BooleanProvider : PreviewParameterProvider<Boolean> { + override val values: Sequence<Boolean> + get() = sequenceOf(false, true) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Extensions.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Extensions.kt new file mode 100644 index 0000000000..a6fa194fca --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Extensions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +internal fun Boolean.asInt(): Int = if (this) 1 else 0 + +val allBooleans = listOf(false, true) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt new file mode 100644 index 0000000000..f6edd1a8fb --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import io.element.android.libraries.designsystem.BuildConfig +import timber.log.Timber + +// Note the inline function below which ensures that this function is essentially +// copied at the call site to ensure that its logging only recompositions from the +// original call site. +@Composable +fun LogCompositions(tag: String, msg: String) { + if (BuildConfig.DEBUG) { + val ref = remember { Ref(0) } + SideEffect { ref.value++ } + Timber.d(tag, "Compositions: $msg ${ref.value}") + } +} + +class Ref(var value: Int) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnLifecycleEvent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnLifecycleEvent.kt new file mode 100644 index 0000000000..630434e060 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnLifecycleEvent.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +@Composable +fun OnLifecycleEvent(onEvent: (owner: LifecycleOwner, event: Lifecycle.Event) -> Unit) { + val eventHandler = rememberUpdatedState(onEvent) + val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) + + DisposableEffect(lifecycleOwner.value) { + val lifecycle = lifecycleOwner.value.lifecycle + val observer = LifecycleEventObserver { owner, event -> + eventHandler.value(owner, event) + } + + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/PairCombinedProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/PairCombinedProvider.kt new file mode 100644 index 0000000000..0f53d44e44 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/PairCombinedProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class PairCombinedProvider<T1, T2>( + private val provider: Pair<PreviewParameterProvider<T1>, PreviewParameterProvider<T2>> +) : PreviewParameterProvider<Pair<T1, T2>> { + override val values: Sequence<Pair<T1, T2>> + get() = provider.first.values.flatMap { first -> + provider.second.values.map { second -> + first to second + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt new file mode 100644 index 0000000000..f4e44779d1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState]. + */ +class SnackbarDispatcher { + private val mutex = Mutex() + + private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null) + val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow() + + suspend fun post(message: SnackbarMessage) { + mutex.withLock { + _snackbarMessage.update { message } + } + } + + suspend fun clear() { + mutex.withLock { + _snackbarMessage.update { null } + } + } +} + +/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */ +val LocalSnackbarDispatcher = compositionLocalOf<SnackbarDispatcher> { SnackbarDispatcher() } + +@Composable +fun SnackbarDispatcher.collectSnackbarMessageAsState(): State<SnackbarMessage?> { + return snackbarMessage.collectAsState(initial = null) +} + +@Composable +fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val snackbarMessageText = snackbarMessage?.let { + stringResource(id = snackbarMessage.messageResId) + } + val dispatcher = LocalSnackbarDispatcher.current + LaunchedEffect(snackbarMessage) { + if (snackbarMessageText == null) return@LaunchedEffect + launch { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = snackbarMessage.duration, + ) + if (isActive) { + dispatcher.clear() + } + } + } + return snackbarHostState +} + +data class SnackbarMessage( + @StringRes val messageResId: Int, + val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionResId: Int? = null, + val action: () -> Unit = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/StringProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/StringProvider.kt new file mode 100644 index 0000000000..4773259985 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/StringProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class StringProvider(val strings: List<String>) : PreviewParameterProvider<String> { + override val values: Sequence<String> + get() = strings.asSequence() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt new file mode 100644 index 0000000000..33baf19dce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.designsystem.utils + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +fun WindowInsets.copy( + top: Int? = null, + right: Int? = null, + bottom: Int? = null, + left: Int? = null +): WindowInsets { + val density = LocalDensity.current + val direction = LocalLayoutDirection.current + return WindowInsets( + top = top ?: this.getTop(density), + right = right ?: this.getRight(density, direction), + bottom = bottom ?: this.getBottom(density), + left = left ?: this.getLeft(density, direction) + ) +} diff --git a/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png new file mode 100644 index 0000000000..df30707317 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-night/onboarding_bg.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/pin.xml b/libraries/designsystem/src/main/res/drawable-night/pin.xml new file mode 100644 index 0000000000..b527ef7f5f --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable-night/pin.xml @@ -0,0 +1,19 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="50dp" + android:height="54dp" + android:viewportWidth="50" + android:viewportHeight="54"> + <path + android:pathData="M25,54L18.938,48L31.062,48L25,54Z" + android:fillColor="#EBEEF2"/> + <path + android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0" + android:fillColor="#EBEEF2"/> + <group> + <clip-path + android:pathData="M13,13h24v24h-24z"/> + <path + android:pathData="M25,13C20.356,13 16.6,16.858 16.6,21.629C16.6,26.769 21.904,33.857 24.088,36.556C24.568,37.148 25.444,37.148 25.924,36.556C28.096,33.857 33.4,26.769 33.4,21.629C33.4,16.858 29.644,13 25,13ZM25,24.71C23.344,24.71 22,23.33 22,21.629C22,19.928 23.344,18.547 25,18.547C26.656,18.547 28,19.928 28,21.629C28,23.33 26.656,24.71 25,24.71Z" + android:fillColor="#101317"/> + </group> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/element_logo.xml b/libraries/designsystem/src/main/res/drawable/element_logo.xml new file mode 100644 index 0000000000..0101c0d541 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/element_logo.xml @@ -0,0 +1,26 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="110dp" + android:height="110dp" + android:viewportWidth="110" + android:viewportHeight="110"> + <path + android:pathData="M55,110C85.38,110 110,85.38 110,55C110,24.62 85.38,0 55,0C24.62,0 0,24.62 0,55C0,85.38 24.62,110 55,110Z" + android:fillColor="#0DBD8B" + android:fillType="evenOdd"/> + <path + android:pathData="M44.94,25.63C44.94,23.41 46.75,21.61 48.97,21.61C64.05,21.61 76.27,33.81 76.27,48.85C76.27,51.07 74.47,52.87 72.25,52.87C70.02,52.87 68.22,51.07 68.22,48.85C68.22,38.25 59.6,29.65 48.97,29.65C46.75,29.65 44.94,27.85 44.94,25.63Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> + <path + android:pathData="M84.36,44.83C86.59,44.83 88.39,46.63 88.39,48.85C88.39,63.9 76.17,76.1 61.09,76.1C58.87,76.1 57.06,74.3 57.06,72.08C57.06,69.86 58.87,68.06 61.09,68.06C71.72,68.06 80.34,59.46 80.34,48.85C80.34,46.63 82.14,44.83 84.36,44.83Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> + <path + android:pathData="M65.12,84.37C65.12,86.59 63.32,88.39 61.09,88.39C46.01,88.39 33.79,76.19 33.79,61.15C33.79,58.93 35.59,57.13 37.82,57.13C40.04,57.13 41.85,58.93 41.85,61.15C41.85,71.75 50.46,80.35 61.09,80.35C63.32,80.35 65.12,82.15 65.12,84.37Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> + <path + android:pathData="M25.63,65.17C23.41,65.17 21.61,63.37 21.61,61.15C21.61,46.1 33.83,33.9 48.91,33.9C51.13,33.9 52.94,35.7 52.94,37.92C52.94,40.14 51.13,41.94 48.91,41.94C38.28,41.94 29.66,50.54 29.66,61.15C29.66,63.37 27.86,65.17 25.63,65.17Z" + android:fillColor="#ffffff" + android:fillType="evenOdd"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml b/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml new file mode 100644 index 0000000000..96a220a5cd --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml @@ -0,0 +1,27 @@ +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<vector android:autoMirrored="true" + android:height="24dp" + android:tint="#000000" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> + <path + android:fillColor="@android:color/white" + android:pathData="M10,9V5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml b/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml new file mode 100644 index 0000000000..739053947d --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml @@ -0,0 +1,27 @@ +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<vector android:autoMirrored="true" + android:height="24dp" + android:tint="#000000" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml b/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000000..6910b0421a --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,26 @@ +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<vector android:height="24dp" + android:tint="#000000" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp" + xmlns:android="http://schemas.android.com/apk/res/android"> + <path + android:fillColor="@android:color/white" + android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_delete.xml b/libraries/designsystem/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..d724c2e05f --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_developer_mode.xml b/libraries/designsystem/src/main/res/drawable/ic_developer_mode.xml new file mode 100644 index 0000000000..282937850b --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_developer_mode.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M344,664L160,480L344,296L400,354L274,480L400,606L344,664ZM200,680L280,680L280,720L680,720L680,680L760,680L760,840Q760,873 736.5,896.5Q713,920 680,920L280,920Q247,920 223.5,896.5Q200,873 200,840L200,680ZM280,280L200,280L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,280L680,280L680,240L280,240L280,280ZM280,800L280,840Q280,840 280,840Q280,840 280,840L680,840Q680,840 680,840Q680,840 680,840L680,800L280,800ZM280,160L680,160L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,160ZM616,664L560,606L686,480L560,354L616,296L800,480L616,664ZM280,160L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,160L280,160ZM280,800L280,800L280,840Q280,840 280,840Q280,840 280,840L280,840Q280,840 280,840Q280,840 280,840L280,800Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml b/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml new file mode 100644 index 0000000000..7d2eec40f5 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_door_open_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="#000000"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520Q457,520 468.5,508.5Q480,497 480,480Q480,463 468.5,451.5Q457,440 440,440Q423,440 411.5,451.5Q400,463 400,480Q400,497 411.5,508.5Q423,520 440,520ZM280,840L280,760L520,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,786L280,840ZM120,840L120,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L840,760L840,840L120,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_edit.xml b/libraries/designsystem/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000000..f64fa2f5fb --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M200,760L256,760L601,415L545,359L200,704L200,760ZM772,357L602,189L658,133Q681,110 714.5,110Q748,110 771,133L827,189Q850,212 851,244.5Q852,277 829,300L772,357ZM714,416L290,840L120,840L120,670L544,246L714,416ZM573,387L545,359L545,359L601,415L601,415L573,387Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml b/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml new file mode 100644 index 0000000000..73b092ea47 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_edit_square.xml @@ -0,0 +1,26 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:tint="#000000" + android:width="21dp" + android:height="22dp" + android:viewportWidth="21" + android:viewportHeight="22"> + <path + android:pathData="M1.5,21.7C1.1,21.7 0.75,21.55 0.45,21.25C0.15,20.95 0,20.6 0,20.2V5.2C0,4.8 0.15,4.45 0.45,4.15C0.75,3.85 1.1,3.7 1.5,3.7H11.625L10.125,5.2H1.5V20.2H16.5V11.5L18,10V20.2C18,20.6 17.85,20.95 17.55,21.25C17.25,21.55 16.9,21.7 16.5,21.7H1.5ZM13.55,3.9L14.625,4.95L7.5,12.05V14.2H9.625L16.775,7.05L17.825,8.1L10.7,15.25C10.567,15.383 10.404,15.492 10.212,15.575C10.021,15.658 9.825,15.7 9.625,15.7H6.75C6.533,15.7 6.354,15.629 6.213,15.488C6.071,15.346 6,15.167 6,14.95V12.075C6,11.875 6.042,11.679 6.125,11.488C6.208,11.296 6.317,11.133 6.45,11L13.55,3.9ZM17.825,8.1L13.55,3.9L16.05,1.4C16.333,1.117 16.688,0.975 17.112,0.975C17.538,0.975 17.892,1.125 18.175,1.425L20.275,3.55C20.558,3.85 20.7,4.204 20.7,4.613C20.7,5.021 20.55,5.367 20.25,5.65L17.825,8.1Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_forward.xml b/libraries/designsystem/src/main/res/drawable/ic_forward.xml new file mode 100644 index 0000000000..9608767c8d --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_forward.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + <path + android:fillColor="@android:color/white" + android:pathData="M120,760L120,600Q120,517 178.5,458.5Q237,400 320,400L688,400L544,256L600,200L840,440L600,680L544,624L688,480L320,480Q270,480 235,515Q200,550 200,600L200,760L120,760Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_groups.xml b/libraries/designsystem/src/main/res/drawable/ic_groups.xml new file mode 100644 index 0000000000..9e87f1d533 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_groups.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/black" + android:pathData="M1.365,17.788C1.12,17.788 0.915,17.705 0.749,17.54C0.583,17.374 0.5,17.168 0.5,16.923V16.569C0.5,15.894 0.844,15.346 1.531,14.927C2.219,14.508 3.126,14.298 4.251,14.298C4.454,14.298 4.647,14.305 4.829,14.318C5.011,14.332 5.187,14.355 5.358,14.389C5.168,14.681 5.028,14.995 4.94,15.332C4.852,15.668 4.808,16.024 4.808,16.4V17.788H1.365ZM7.408,17.788C7.147,17.788 6.931,17.702 6.759,17.529C6.586,17.355 6.5,17.141 6.5,16.885V16.438C6.5,15.483 7.006,14.713 8.018,14.128C9.03,13.543 10.356,13.25 11.996,13.25C13.651,13.25 14.982,13.543 15.989,14.128C16.996,14.713 17.5,15.483 17.5,16.438V16.885C17.5,17.141 17.413,17.355 17.24,17.529C17.067,17.702 16.852,17.788 16.596,17.788H7.408ZM19.192,17.788V16.4C19.192,16.024 19.145,15.668 19.05,15.332C18.955,14.995 18.819,14.681 18.642,14.389C18.813,14.355 18.989,14.332 19.17,14.318C19.352,14.305 19.545,14.298 19.75,14.298C20.875,14.298 21.781,14.508 22.469,14.927C23.156,15.346 23.5,15.894 23.5,16.569V16.923C23.5,17.168 23.417,17.374 23.251,17.54C23.085,17.705 22.88,17.788 22.635,17.788H19.192ZM12,14.75C10.954,14.75 10.059,14.892 9.315,15.176C8.572,15.46 8.159,15.799 8.077,16.192V16.288H15.923V16.192C15.831,15.788 15.415,15.447 14.677,15.168C13.938,14.889 13.046,14.75 12,14.75ZM4.25,13.327C3.777,13.327 3.375,13.159 3.044,12.824C2.713,12.489 2.548,12.086 2.548,11.615C2.548,11.145 2.713,10.742 3.044,10.407C3.375,10.071 3.777,9.904 4.25,9.904C4.723,9.904 5.127,10.071 5.461,10.407C5.794,10.742 5.961,11.145 5.961,11.615C5.961,12.086 5.794,12.489 5.459,12.824C5.124,13.159 4.721,13.327 4.25,13.327ZM19.75,13.327C19.277,13.327 18.873,13.159 18.539,12.824C18.205,12.489 18.038,12.086 18.038,11.615C18.038,11.145 18.206,10.742 18.541,10.407C18.876,10.071 19.279,9.904 19.75,9.904C20.223,9.904 20.625,10.071 20.956,10.407C21.286,10.742 21.452,11.145 21.452,11.615C21.452,12.086 21.286,12.489 20.956,12.824C20.625,13.159 20.223,13.327 19.75,13.327ZM12,12.5C11.279,12.5 10.666,12.248 10.161,11.743C9.656,11.238 9.404,10.625 9.404,9.904C9.404,9.183 9.656,8.57 10.161,8.065C10.666,7.56 11.279,7.308 12,7.308C12.721,7.308 13.334,7.56 13.839,8.065C14.344,8.57 14.596,9.183 14.596,9.904C14.596,10.625 14.344,11.238 13.839,11.743C13.334,12.248 12.721,12.5 12,12.5ZM12.002,8.808C11.691,8.808 11.431,8.913 11.22,9.122C11.009,9.332 10.904,9.592 10.904,9.902C10.904,10.212 11.009,10.473 11.218,10.684C11.428,10.895 11.688,11 11.998,11C12.308,11 12.569,10.895 12.78,10.685C12.991,10.476 13.096,10.216 13.096,9.906C13.096,9.595 12.991,9.335 12.781,9.124C12.572,8.913 12.312,8.808 12.002,8.808Z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_reply.xml b/libraries/designsystem/src/main/res/drawable/ic_reply.xml new file mode 100644 index 0000000000..ac41dfaa55 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + <path + android:fillColor="@android:color/white" + android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L272,480L416,624L360,680L120,440L360,200L416,256L272,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_report_content.xml b/libraries/designsystem/src/main/res/drawable/ic_report_content.xml new file mode 100644 index 0000000000..18c9c2f95e --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_report_content.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,600Q497,600 508.5,588.5Q520,577 520,560Q520,543 508.5,531.5Q497,520 480,520Q463,520 451.5,531.5Q440,543 440,560Q440,577 451.5,588.5Q463,600 480,600ZM440,440L520,440L520,200L440,200L440,440ZM80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640L160,640Z"/> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/ic_share.xml b/libraries/designsystem/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000000..d38f7ae5f7 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_share.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/black" + android:pathData="M18.001,21.75C17.237,21.75 16.588,21.483 16.053,20.948C15.518,20.413 15.25,19.764 15.25,19C15.25,18.875 15.26,18.746 15.28,18.612C15.3,18.478 15.33,18.355 15.369,18.242L7.973,13.911C7.709,14.174 7.408,14.38 7.071,14.528C6.734,14.676 6.377,14.75 6,14.75C5.236,14.75 4.587,14.483 4.052,13.948C3.517,13.414 3.25,12.765 3.25,12.001C3.25,11.238 3.517,10.588 4.052,10.053C4.587,9.518 5.236,9.25 6,9.25C6.377,9.25 6.734,9.324 7.071,9.472C7.408,9.62 7.709,9.826 7.973,10.089L15.369,5.758C15.33,5.645 15.3,5.522 15.28,5.388C15.26,5.254 15.25,5.125 15.25,5C15.25,4.236 15.517,3.587 16.052,3.052C16.586,2.517 17.235,2.25 17.999,2.25C18.762,2.25 19.412,2.517 19.947,3.052C20.482,3.586 20.75,4.235 20.75,4.999C20.75,5.762 20.483,6.412 19.948,6.947C19.413,7.482 18.764,7.75 18,7.75C17.623,7.75 17.266,7.676 16.929,7.528C16.592,7.38 16.291,7.174 16.027,6.912L8.631,11.242C8.67,11.355 8.7,11.478 8.72,11.611C8.74,11.745 8.75,11.874 8.75,11.998C8.75,12.122 8.74,12.252 8.72,12.387C8.7,12.521 8.67,12.645 8.631,12.758L16.027,17.088C16.291,16.826 16.592,16.62 16.929,16.472C17.266,16.324 17.623,16.25 18,16.25C18.764,16.25 19.413,16.517 19.948,17.052C20.483,17.586 20.75,18.235 20.75,18.999C20.75,19.762 20.483,20.412 19.948,20.947C19.414,21.482 18.765,21.75 18.001,21.75ZM18,6.25C18.347,6.25 18.643,6.129 18.886,5.886C19.128,5.643 19.25,5.347 19.25,5C19.25,4.653 19.128,4.357 18.886,4.114C18.643,3.871 18.347,3.75 18,3.75C17.653,3.75 17.357,3.871 17.114,4.114C16.871,4.357 16.75,4.653 16.75,5C16.75,5.347 16.871,5.643 17.114,5.886C17.357,6.129 17.653,6.25 18,6.25ZM6,13.25C6.347,13.25 6.643,13.128 6.886,12.886C7.129,12.643 7.25,12.347 7.25,12C7.25,11.653 7.129,11.357 6.886,11.114C6.643,10.871 6.347,10.75 6,10.75C5.653,10.75 5.357,10.871 5.114,11.114C4.871,11.357 4.75,11.653 4.75,12C4.75,12.347 4.871,12.643 5.114,12.886C5.357,13.128 5.653,13.25 6,13.25ZM18,20.25C18.347,20.25 18.643,20.128 18.886,19.886C19.128,19.643 19.25,19.347 19.25,19C19.25,18.653 19.128,18.357 18.886,18.114C18.643,17.871 18.347,17.75 18,17.75C17.653,17.75 17.357,17.871 17.114,18.114C16.871,18.357 16.75,18.653 16.75,19C16.75,19.347 16.871,19.643 17.114,19.886C17.357,20.128 17.653,20.25 18,20.25Z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/onboarding_bg.png b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png new file mode 100644 index 0000000000..2af2e1c907 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable/onboarding_bg.png differ diff --git a/libraries/designsystem/src/main/res/drawable/pin.xml b/libraries/designsystem/src/main/res/drawable/pin.xml new file mode 100644 index 0000000000..7f26c5ac4b --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/pin.xml @@ -0,0 +1,19 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="50dp" + android:height="54dp" + android:viewportWidth="50" + android:viewportHeight="54"> + <path + android:pathData="M25,54L18.938,48L31.062,48L25,54Z" + android:fillColor="#1B1D22"/> + <path + android:pathData="M25,25m-25,0a25,25 0,1 1,50 0a25,25 0,1 1,-50 0" + android:fillColor="#1B1D22"/> + <group> + <clip-path + android:pathData="M13,13h24v24h-24z"/> + <path + android:pathData="M25,13C20.356,13 16.6,16.858 16.6,21.629C16.6,26.769 21.904,33.857 24.088,36.556C24.568,37.148 25.444,37.148 25.924,36.556C28.096,33.857 33.4,26.769 33.4,21.629C33.4,16.858 29.644,13 25,13ZM25,24.71C23.344,24.71 22,23.33 22,21.629C22,19.928 23.344,18.547 25,18.547C26.656,18.547 28,19.928 28,21.629C28,23.33 26.656,24.71 25,24.71Z" + android:fillColor="#ffffff"/> + </group> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/sample_avatar.xml b/libraries/designsystem/src/main/res/drawable/sample_avatar.xml new file mode 100644 index 0000000000..3e2436ca1d --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/sample_avatar.xml @@ -0,0 +1,52 @@ +<!-- + ~ Copyright 2015 Google Inc. All Rights Reserved. + ~ + ~ 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. + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="128dp" + android:height="128dp" + android:viewportHeight="128.0" + android:viewportWidth="128.0"> + <path + android:fillColor="#448AFF" + android:pathData="M0 0h128v128h-128z" /> + <path + android:fillColor="#FF000000" + android:pathData="M73 18.7c-4.8 0,-9.7 0.8,-14 2.3,-0.1 0.1,-0.2 0.2,-0.4 0.3l-7.3 4.6c-0.6 0.4,-1.4 0.4,-2 0.1,-0.3,-0.2,-0.6,-0.4,-0.8,-0.7l-0.7,-1.1c-0.6,-1,-0.3,-2.2 0.6,-2.8l7.3,-4.6c0.4,-0.2 0.8,-0.3 1.2,-0.3,-5.5,-3,-23.7,-10.7,-33.7 10.7,-11.8 25.4 11 50.2,-14.4 62.6 0 0 26.2 13.7 40.9,-24.8 3.7 3.2 8.8 5.8 16 7.4,-0.6,-5.6 0.8,-9.8,-2.1,-12.8,-1.3,-1.4,-2.7,-1.5,-4,-2.4,-0.7,-0.5,-1.4,-0.9,-2,-1.3,-1.5,-0.9,-2.6,-1.3,-2.8,-1.4,-1.1,-1,-1.9,-2.4,-2.1,-4.1,-0.5,-3.6,-2.2,-6.9 1.1,-7.4 0.8,-0.1 1.6,-0.1 2.4 0.2 8,-1.5 11.6,-6.7 12.8,-8.9 3.4 4.8 11.7 9.8 31.9 6.8 0.3 1.1 0.6 1.2 0.8 2.4l0.5,-1.3c-0.1,-13,-13.2,-23.5,-29.2,-23.5zM56.1 43.2zM61.4 89.7s6 8.6 19.4 9.7c5.1 0.4 11.3,-0.3 18.6,-2.9,-0.1,-0.6,-0.3,-1.2,-0.4,-1.7,-0.2,-1.1,-3.2,-18,-3.4,-23.6,-0.2,-6.2 0.6,-10 1.6,-12.4h7.3l-2.1,-8.5c-0.1,-2,-0.4,-3.9,-0.6,-5.6 0,-0.3,-0.1,-0.7,-0.2,-1,-0.2,-1.1,-0.4,-2.3,-0.8,-3.4,-20.2 3,-28.5,-2,-31.9,-6.8,-1.2 2.1,-4.8 7.4,-12.8 8.9,-0.8,-0.2,-1.6,-0.3,-2.4,-0.2,-3.3 0.5,-5.6 3.8,-5.1 7.4 0.2 1.7 1 3.1 2.1 4.1 0.2 0.1 1.3 0.5 2.8 1.4 0.6 0.4 1.3 0.8 2 1.3 1.3 0.9 2.6 2 4 3.4 2.9 3 5.6 7.2 6.1 12.8 0.5 4.5,-0.6 10.2,-4.2 17.1zm27.5,-41.2c0.7 0 1.3 0.6 1.3 1.3s-0.6 1.3,-1.3 1.3,-1.3,-0.6,-1.3,-1.3 0.6,-1.3 1.3,-1.3zM56.1 43.2c0.1,-0.1 0,-0.1 0 0zM53.7 100.9l0.2,-0.2,-0.2 0.2z" /> + <path + android:fillColor="#FF000000" + android:pathData="M88.9,49.8 m-2.0, 0 a 2.0,2.0 0 1,1 4.0,0 a2.0,2.0 0 1,1 -4.0,0" /> + <path + android:fillColor="#FF000000" + android:pathData="M80.8 99.3c-13.3,-1.1,-19.4,-9.7,-19.4,-9.7,-1.8 3.4,-4.3 7.1,-7.5 11l-0.3 0.3c-0.4 0.5,-0.9 1.1,-1.4 1.7,-0.7 0.9,-1.6 2.1,-2.8 3.7,-2.3 3.2,-5.4 7.8,-8.8 13.5,-1.4 2.4,-2.9 5.1,-4.4 8 0 0 0 0.1,-0.1 0.1h71.3c-0.6,-1.6,-1.3,-3.2,-1.7,-4.8,-2.2,-8.6,-4.6,-17.9,-6.5,-26.8,-7.2 2.8,-13.3 3.5,-18.4 3zM55.7 16.7l-7.3 4.6c-1 0.6,-1.3 1.9,-0.6 2.8l0.7 1.1c0.2 0.3 0.5 0.6 0.8 0.7 0.6 0.3 1.4 0.3 2,-0.1l7.3,-4.6 0.4,-0.3c0.7,-0.7 0.8,-1.7 0.3,-2.5l-0.7,-1.1c-0.4,-0.6,-1,-0.9,-1.6,-1,-0.5 0,-1 0.1,-1.3 0.4z" /> + <path + android:fillColor="#444" + android:pathData="M73 18.7c-4.8 0,-9.7 0.8,-14 2.3,-0.1 0.1,-0.2 0.2,-0.4 0.3l-7.3 4.6c-0.6 0.4,-1.4 0.4,-2 0.1,-0.3,-0.2,-0.6,-0.4,-0.8,-0.7l-0.7,-1.1c-0.6,-1,-0.3,-2.2 0.6,-2.8l7.3,-4.6c0.4,-0.2 0.8,-0.3 1.2,-0.3,-5.5,-3,-23.7,-10.7,-33.7 10.7,-11.8 25.4 11 50.2,-14.4 62.6 0 0 26.2 13.7 40.9,-24.8 3.7 3.2 8.8 5.8 16 7.4,-0.6,-5.6 0.8,-9.8,-2.1,-12.8,-1.3,-1.4,-2.7,-1.5,-4,-2.4,-0.7,-0.5,-1.4,-0.9,-2,-1.3,-1.5,-0.9,-2.6,-1.3,-2.8,-1.4,-1.1,-1,-1.9,-2.4,-2.1,-4.1,-0.5,-3.6,-2.2,-6.9 1.1,-7.4 0.8,-0.1 1.6,-0.1 2.4 0.2 8,-1.5 11.6,-6.7 12.8,-8.9 3.4 4.8 11.7 9.8 31.9 6.8 0.3 1.1 0.6 1.2 0.8 2.4l0.5,-1.3c-0.1,-13,-13.2,-23.5,-29.2,-23.5zM56.1 43.2z" /> + <path + android:fillColor="#FFE0B2" + android:pathData="M61.4 89.7s6 8.6 19.4 9.7c5.1 0.4 11.3,-0.3 18.6,-2.9,-0.1,-0.6,-0.3,-1.2,-0.4,-1.7,-0.2,-1.1,-3.2,-18,-3.4,-23.6,-0.2,-6.2 0.6,-10 1.6,-12.4h7.3l-2.1,-8.5c-0.1,-2,-0.4,-3.9,-0.6,-5.6 0,-0.3,-0.1,-0.7,-0.2,-1,-0.2,-1.1,-0.4,-2.3,-0.8,-3.4,-20.2 3,-28.5,-2,-31.9,-6.8,-1.2 2.1,-4.8 7.4,-12.8 8.9,-0.8,-0.2,-1.6,-0.3,-2.4,-0.2,-3.3 0.5,-5.6 3.8,-5.1 7.4 0.2 1.7 1 3.1 2.1 4.1 0.2 0.1 1.3 0.5 2.8 1.4 0.6 0.4 1.3 0.8 2 1.3 1.3 0.9 2.6 2 4 3.4 2.9 3 5.6 7.2 6.1 12.8 0.5 4.5,-0.6 10.2,-4.2 17.1zm27.5,-41.2c0.7 0 1.3 0.6 1.3 1.3s-0.6 1.3,-1.3 1.3,-1.3,-0.6,-1.3,-1.3 0.6,-1.3 1.3,-1.3zM56.1 43.2c0.1,-0.1 0,-0.1 0 0z" /> + <path + android:fillColor="#FFCC80" + android:pathData="M53.7 100.9l0.2,-0.2,-0.2 0.2z" /> + <path + android:fillColor="#444" + android:pathData="M88.9,49.8 m-2.0, 0 a 2.0,2.0 0 1,1 4.0,0 a2.0,2.0 0 1,1 -4.0,0" /> + <path + android:fillColor="#FF5722" + android:pathData="M80.8 99.3c-13.3,-1.1,-19.4,-9.7,-19.4,-9.7,-1.8 3.4,-4.3 7.1,-7.5 11l-0.3 0.3c-0.4 0.5,-0.9 1.1,-1.4 1.7,-0.7 0.9,-1.6 2.1,-2.8 3.7,-2.3 3.2,-5.4 7.8,-8.8 13.5,-1.4 2.4,-2.9 5.1,-4.4 8 0 0 0 0.1,-0.1 0.1h71.3c-0.6,-1.6,-1.3,-3.2,-1.7,-4.8,-2.2,-8.6,-4.6,-17.9,-6.5,-26.8,-7.2 2.8,-13.3 3.5,-18.4 3z" /> + <path + android:fillColor="#00BFA5" + android:pathData="M55.7 16.7l-7.3 4.6c-1 0.6,-1.3 1.9,-0.6 2.8l0.7 1.1c0.2 0.3 0.5 0.6 0.8 0.7 0.6 0.3 1.4 0.3 2,-0.1l7.3,-4.6 0.4,-0.3c0.7,-0.7 0.8,-1.7 0.3,-2.5l-0.7,-1.1c-0.4,-0.6,-1,-0.9,-1.6,-1,-0.5 0,-1 0.1,-1.3 0.4z" /> +</vector> diff --git a/libraries/designsystem/src/main/res/drawable/sample_background.webp b/libraries/designsystem/src/main/res/drawable/sample_background.webp new file mode 100644 index 0000000000..b05f3b33a0 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable/sample_background.webp differ diff --git a/libraries/di/.gitignore b/libraries/di/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/di/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/di/build.gradle.kts b/libraries/di/build.gradle.kts new file mode 100644 index 0000000000..15cae9e289 --- /dev/null +++ b/libraries/di/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + id("com.android.lint") +} + +dependencies { + api(libs.inject) +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/AppScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/AppScope.kt new file mode 100644 index 0000000000..2b40d59894 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/AppScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +abstract class AppScope private constructor() diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt new file mode 100644 index 0000000000..2108678097 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/ApplicationContext.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +import javax.inject.Qualifier + +@Qualifier annotation class ApplicationContext diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DaggerComponentOwner.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DaggerComponentOwner.kt new file mode 100644 index 0000000000..57f5540c16 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DaggerComponentOwner.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +/** + * A [DaggerComponentOwner] is anything that "owns" a Dagger Component. + * + */ +interface DaggerComponentOwner { + /** This is either a component, or a list of components. */ + val daggerComponent: Any +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt new file mode 100644 index 0000000000..2a4f9b8ac1 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +import javax.inject.Qualifier + +@Qualifier annotation class DefaultPreferences diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt new file mode 100644 index 0000000000..af25c4cda5 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +abstract class RoomScope private constructor() diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/SessionScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/SessionScope.kt new file mode 100644 index 0000000000..8ebd6ecaee --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/SessionScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +abstract class SessionScope private constructor() diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/SingleIn.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/SingleIn.kt new file mode 100644 index 0000000000..42a6b860ca --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/SingleIn.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.di + +import javax.inject.Scope +import kotlin.reflect.KClass + +@Scope +@Retention(AnnotationRetention.RUNTIME) +annotation class SingleIn(val clazz: KClass<*>) diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts new file mode 100644 index 0000000000..40db7f7fde --- /dev/null +++ b/libraries/encrypted-db/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.encrypteddb" + + buildTypes { + release { + isMinifyEnabled = true + consumerProguardFiles("consumer-proguard-rules.pro") + } + } +} + +dependencies { + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.androidx.security.crypto) + + implementation(projects.libraries.androidutils) +} diff --git a/libraries/encrypted-db/consumer-proguard-rules.pro b/libraries/encrypted-db/consumer-proguard-rules.pro new file mode 100644 index 0000000000..5d01f21e9b --- /dev/null +++ b/libraries/encrypted-db/consumer-proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# Prevent ProGuard from renaming internal SQLCipher classes, which breaks the library. +# From https://github.com/sqlcipher/android-database-sqlcipher#proguard +-keep class net.sqlcipher.** { *; } diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt new file mode 100644 index 0000000000..5258c388c5 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.encrypteddb + +import android.content.Context +import com.squareup.sqldelight.android.AndroidSqliteDriver +import com.squareup.sqldelight.db.SqlDriver +import io.element.encrypteddb.passphrase.PassphraseProvider +import net.sqlcipher.database.SupportFactory + +/** + * Creates an encrypted version of the [SqlDriver] using SQLCipher's [SupportFactory]. + * @param passphraseProvider Provides the passphrase needed to use the SQLite database with SQLCipher. + */ +class SqlCipherDriverFactory( + private val passphraseProvider: PassphraseProvider, +) { + /** + * Returns a valid [SqlDriver] with SQLCipher support. + * @param schema The SQLite DB schema. + * @param name The name of the database to create. + * @param context Android [Context], used to instantiate the driver. + */ + fun create(schema: SqlDriver.Schema, name: String, context: Context): SqlDriver { + val passphrase = passphraseProvider.getPassphrase() + val factory = SupportFactory(passphrase) + return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory) + } +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt new file mode 100644 index 0000000000..a12c5f9cfb --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.encrypteddb.passphrase + +/** + * An abstraction to implement secure providers for SQLCipher passphrases. + */ +interface PassphraseProvider { + /** + * Returns a passphrase for SQLCipher in [ByteArray] format. + */ + fun getPassphrase(): ByteArray +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt new file mode 100644 index 0000000000..dd09188bc4 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.encrypteddb.passphrase + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import io.element.android.libraries.androidutils.file.EncryptedFileFactory +import java.io.File +import java.security.SecureRandom + +/** + * Provides a secure passphrase for SQLCipher by generating a random secret and storing it into an [EncryptedFile]. + * @param context Android [Context], used by [EncryptedFile] for cryptographic operations. + * @param file Destination file where the key will be stored. + * @param secretSize Length of the generated secret. + */ +class RandomSecretPassphraseProvider( + private val context: Context, + private val file: File, + private val secretSize: Int = 256, +) : PassphraseProvider { + + override fun getPassphrase(): ByteArray { + val encryptedFile = EncryptedFileFactory(context).create(file) + return if (!file.exists()) { + val secret = generateSecret() + encryptedFile.openFileOutput().use { it.write(secret) } + secret + } else { + encryptedFile.openFileInput().use { it.readBytes() } + } + } + + private fun generateSecret(): ByteArray { + val buffer = ByteArray(size = secretSize) + SecureRandom().nextBytes(buffer) + return buffer + } +} diff --git a/libraries/eventformatter/api/build.gradle.kts b/libraries/eventformatter/api/build.gradle.kts new file mode 100644 index 0000000000..ec2a56d780 --- /dev/null +++ b/libraries/eventformatter/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.eventformatter.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt new file mode 100644 index 0000000000..4dd5978bc6 --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLastMessageFormatter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.api + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +interface RoomLastMessageFormatter { + fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? +} diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt new file mode 100644 index 0000000000..6a966f1aba --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.api + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +interface TimelineEventFormatter { + fun format(event: EventTimelineItem): CharSequence? +} diff --git a/libraries/eventformatter/impl/build.gradle.kts b/libraries/eventformatter/impl/build.gradle.kts new file mode 100644 index 0000000000..3bee2df488 --- /dev/null +++ b/libraries/eventformatter/impl/build.gradle.kts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.eventformatter.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) + api(projects.libraries.eventformatter.api) + + testImplementation(projects.services.toolbox.impl) + testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt new file mode 100644 index 0000000000..f1af61c8e7 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultRoomLastMessageFormatter @Inject constructor( + private val sp: StringProvider, + private val matrixClient: MatrixClient, + private val roomMembershipContentFormatter: RoomMembershipContentFormatter, + private val profileChangeContentFormatter: ProfileChangeContentFormatter, + private val stateContentFormatter: StateContentFormatter, +) : RoomLastMessageFormatter { + + override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + val isOutgoing = matrixClient.isMe(event.sender) + val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value + return when (val content = event.content) { + is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom) + RedactedContent -> { + val message = sp.getString(CommonStrings.common_message_removed) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is StickerContent -> { + content.body + } + is UnableToDecryptContent -> { + val message = sp.getString(CommonStrings.common_decryption_error) + if (!isDmRoom) { + prefix(message, senderDisplayName) + } else { + message + } + } + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList) + } + is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { + prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom) + } + } + } + + private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? { + val messageType: MessageType = messageContent.type ?: return null + + val internalMessage = when (messageType) { + // Doesn't need a prefix + is EmoteMessageType -> { + return "- $senderDisplayName ${messageType.body}" + } + is TextMessageType -> { + messageType.body + } + is VideoMessageType -> { + sp.getString(CommonStrings.common_video) + } + is ImageMessageType -> { + sp.getString(CommonStrings.common_image) + } + is LocationMessageType -> { + sp.getString(CommonStrings.common_shared_location) + } + is FileMessageType -> { + sp.getString(CommonStrings.common_file) + } + is AudioMessageType -> { + sp.getString(CommonStrings.common_audio) + } + UnknownMessageType -> { + sp.getString(CommonStrings.common_unsupported_event) + } + is NoticeMessageType -> { + messageType.body + } + } + return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom) + } + + private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) { + message + } else { + prefix(message, senderDisplayName) + } + + private fun prefix(message: String, senderDisplayName: String): AnnotatedString { + return buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(senderDisplayName) + } + append(": ") + append(message) + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt new file mode 100644 index 0000000000..8f89233a31 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultTimelineEventFormatter @Inject constructor( + private val sp: StringProvider, + private val matrixClient: MatrixClient, + private val buildMeta: BuildMeta, + private val roomMembershipContentFormatter: RoomMembershipContentFormatter, + private val profileChangeContentFormatter: ProfileChangeContentFormatter, + private val stateContentFormatter: StateContentFormatter, +) : TimelineEventFormatter { + + override fun format(event: EventTimelineItem): CharSequence? { + val isOutgoing = matrixClient.isMe(event.sender) + val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value + return when (val content = event.content) { + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline) + } + RedactedContent, + is StickerContent, + is UnableToDecryptContent, + is MessageContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + is UnknownContent -> { + if (buildMeta.isDebuggable) { + error("You should not use this formatter for this event: $event") + } + sp.getString(CommonStrings.common_unsupported_event) + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt new file mode 100644 index 0000000000..aea43298f9 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +class ProfileChangeContentFormatter @Inject constructor( + private val sp: StringProvider, +) { + fun format( + profileChangeContent: ProfileChangeContent, + senderDisplayName: String, + senderIsYou: Boolean, + ): String? = profileChangeContent.run { + val displayNameChanged = displayName != prevDisplayName + val avatarChanged = avatarUrl != prevAvatarUrl + return when { + avatarChanged && displayNameChanged -> { + val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderDisplayName, senderIsYou) + val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too) + "$message\n$avatarChangedToo" + } + displayNameChanged -> { + if (displayName != null && prevDisplayName != null) { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_changed_from_by_you, prevDisplayName, displayName) + } else { + sp.getString(R.string.state_event_display_name_changed_from, senderDisplayName, prevDisplayName, displayName) + } + } else if (displayName != null) { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_set_by_you, displayName) + } else { + sp.getString(R.string.state_event_display_name_set, senderDisplayName, displayName) + } + } else { + if (senderIsYou) { + sp.getString(R.string.state_event_display_name_removed_by_you, prevDisplayName) + } else { + sp.getString(R.string.state_event_display_name_removed, senderDisplayName, prevDisplayName) + } + } + } + avatarChanged -> { + if (senderIsYou) { + sp.getString(R.string.state_event_avatar_url_changed_by_you) + } else { + sp.getString(R.string.state_event_avatar_url_changed, senderDisplayName) + } + } + else -> null + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt new file mode 100644 index 0000000000..6a65a9bd1e --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject + +class RoomMembershipContentFormatter @Inject constructor( + private val matrixClient: MatrixClient, + private val sp: StringProvider, +) { + fun format( + membershipContent: RoomMembershipContent, + senderDisplayName: String, + senderIsYou: Boolean, + ): CharSequence? { + val userId = membershipContent.userId + val memberIsYou = matrixClient.isMe(userId) + return when (membershipContent.change) { + MembershipChange.JOINED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_join_by_you) + } else { + sp.getString(R.string.state_event_room_join, userId.value) + } + MembershipChange.LEFT -> if (memberIsYou) { + sp.getString(R.string.state_event_room_leave_by_you) + } else { + sp.getString(R.string.state_event_room_leave, userId.value) + } + MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_ban_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_ban, senderDisplayName, userId.value) + } + MembershipChange.UNBANNED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_unban_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_unban, senderDisplayName, userId.value) + } + MembershipChange.KICKED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_remove_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_remove, senderDisplayName, userId.value) + } + MembershipChange.INVITED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_invite_by_you, userId.value) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_invite_you, senderDisplayName) + } else { + sp.getString(R.string.state_event_room_invite, senderDisplayName, userId.value) + } + MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_invite_accepted_by_you) + } else { + sp.getString(R.string.state_event_room_invite_accepted, userId.value) + } + MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_reject_by_you) + } else { + sp.getString(R.string.state_event_room_reject, userId.value) + } + MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value) + } + MembershipChange.KNOCKED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_by_you) + } else { + sp.getString(R.string.state_event_room_knock, userId.value) + } + MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value) + } else { + sp.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value) + } + MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_retracted_by_you) + } else { + sp.getString(R.string.state_event_room_knock_retracted, userId.value) + } + MembershipChange.KNOCK_DENIED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_denied_you, senderDisplayName) + } else { + sp.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value) + } + else -> { + Timber.v("Filtering timeline item for room membership: $membershipContent") + null + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt new file mode 100644 index 0000000000..678e0f1d53 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject + +class StateContentFormatter @Inject constructor( + private val sp: StringProvider, +) { + fun format( + stateContent: StateContent, + senderDisplayName: String, + senderIsYou: Boolean, + renderingMode: RenderingMode, + ): CharSequence? { + return when (val content = stateContent.content) { + is OtherState.RoomAvatar -> { + val hasAvatarUrl = content.url != null + when { + senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed_by_you) + senderIsYou && !hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_removed_by_you) + !senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisplayName) + else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisplayName) + } + } + is OtherState.RoomCreate -> { + if (senderIsYou) { + sp.getString(R.string.state_event_room_created_by_you) + } else { + sp.getString(R.string.state_event_room_created, senderDisplayName) + } + } + is OtherState.RoomEncryption -> sp.getString(CommonStrings.common_encryption_enabled) + is OtherState.RoomName -> { + val hasRoomName = content.name != null + when { + senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed_by_you, content.name) + senderIsYou && !hasRoomName -> sp.getString(R.string.state_event_room_name_removed_by_you) + !senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name) + else -> sp.getString(R.string.state_event_room_name_removed, senderDisplayName) + } + } + is OtherState.RoomThirdPartyInvite -> { + if (content.displayName == null) { + Timber.e("RoomThirdPartyInvite undisplayable due to missing name") + return null + } + if (senderIsYou) { + sp.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName) + } else { + sp.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName) + } + } + is OtherState.RoomTopic -> { + val hasRoomTopic = content.topic != null + when { + senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed_by_you, content.topic) + senderIsYou && !hasRoomTopic -> sp.getString(R.string.state_event_room_topic_removed_by_you) + !senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic) + else -> sp.getString(R.string.state_event_room_topic_removed, senderDisplayName) + } + } + is OtherState.Custom -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "Custom event ${content.eventType}" + } + } + OtherState.PolicyRuleRoom -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleRoom" + } + } + OtherState.PolicyRuleServer -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleServer" + } + } + OtherState.PolicyRuleUser -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "PolicyRuleUser" + } + } + OtherState.RoomAliases -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomAliases" + } + } + OtherState.RoomCanonicalAlias -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomCanonicalAlias" + } + } + OtherState.RoomGuestAccess -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomGuestAccess" + } + } + OtherState.RoomHistoryVisibility -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomHistoryVisibility" + } + } + OtherState.RoomJoinRules -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomJoinRules" + } + } + OtherState.RoomPinnedEvents -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomPinnedEvents" + } + } + OtherState.RoomPowerLevels -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomPowerLevels" + } + } + OtherState.RoomServerAcl -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomServerAcl" + } + } + OtherState.RoomTombstone -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomTombstone" + } + } + OtherState.SpaceChild -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "SpaceChild" + } + } + OtherState.SpaceParent -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "SpaceParent" + } + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt new file mode 100644 index 0000000000..9f85dd4093 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl.mode + +enum class RenderingMode { + RoomList, + Timeline, +} diff --git a/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..69179b1276 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(avatar byl také změněn)"</string> + <string name="state_event_avatar_url_changed">"%1$s změnil(a) svůj profilový obrázek"</string> + <string name="state_event_avatar_url_changed_by_you">"Změnili jste svůj profilový obrázek"</string> + <string name="state_event_display_name_changed_from">"%1$s změnil(a) své zobrazované jméno z %2$s na %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"Změnili jste své zobrazované jméno z %1$s na %2$s"</string> + <string name="state_event_display_name_removed">"%1$s odstranil(a) své zobrazované jméno (%2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Odstranili jste své zobrazované jméno (%1$s)"</string> + <string name="state_event_display_name_set">"%1$s nastavil(a) své zobrazované jméno na %2$s"</string> + <string name="state_event_display_name_set_by_you">"Změnili jste své zobrazované jméno na %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s změnil(a) obrázek místnosti"</string> + <string name="state_event_room_avatar_changed_by_you">"Změnili jste obrázek místnosti"</string> + <string name="state_event_room_avatar_removed">"%1$s odstranili obrázek místnosti"</string> + <string name="state_event_room_avatar_removed_by_you">"Odstranili jste obrázek místnosti"</string> + <string name="state_event_room_ban">"%1$s vykázal(a) %2$s"</string> + <string name="state_event_room_ban_by_you">"Vykázali jste %1$s"</string> + <string name="state_event_room_created">"%1$s založil(a) místnost"</string> + <string name="state_event_room_created_by_you">"Založili jste místnost"</string> + <string name="state_event_room_invite">"%1$s pozval(a) %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s přijal(a) pozvání"</string> + <string name="state_event_room_invite_accepted_by_you">"Přijali jste pozvání"</string> + <string name="state_event_room_invite_by_you">"Pozvali jste %1$s"</string> + <string name="state_event_room_invite_you">"Pozvali jste %1$s"</string> + <string name="state_event_room_join">"%1$s vstoupil(a) do místnosti"</string> + <string name="state_event_room_join_by_you">"Vstoupili jste do místnosti"</string> + <string name="state_event_room_knock">"%1$s požádal(a) o vstup"</string> + <string name="state_event_room_knock_accepted">"%1$s povolil(a) vstoupit %2$s"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s vám povolil(a) vstoupit"</string> + <string name="state_event_room_knock_by_you">"Požádali jste o vstup"</string> + <string name="state_event_room_knock_denied">"%1$s zamítl(a) žádost %2$s o vstup"</string> + <string name="state_event_room_knock_denied_by_you">"Zamítli jste žádost %1$s o vstup"</string> + <string name="state_event_room_knock_denied_you">"%1$s zamítl(a) vaši žádost o vstup"</string> + <string name="state_event_room_knock_retracted">"%1$s již nemá zájem vstoupit"</string> + <string name="state_event_room_knock_retracted_by_you">"Zrušili jste svou žádost vstoupit"</string> + <string name="state_event_room_leave">"%1$s opustil(a) místnost"</string> + <string name="state_event_room_leave_by_you">"Opustili jste místnost"</string> + <string name="state_event_room_name_changed">"%1$s změnil(a) název místnosti na: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Změnili jste název místnosti na: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s odstranil(a) název místnosti"</string> + <string name="state_event_room_name_removed_by_you">"Odstranili jste název místnosti"</string> + <string name="state_event_room_reject">"%1$s pozvánku odmítl(a)"</string> + <string name="state_event_room_reject_by_you">"Odmítli jste pozvání"</string> + <string name="state_event_room_remove">"%1$s odebral(a) %2$s"</string> + <string name="state_event_room_remove_by_you">"Odebrali jste %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s do této místnosti pozval(a) %2$s"</string> + <string name="state_event_room_third_party_invite_by_you">"Poslali jste %1$s pozvání do místnosti"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s zrušil(a) pozvánku do místnosti pro %2$s"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Zrušili jste pozvánku do místnosti pro %1$s"</string> + <string name="state_event_room_topic_changed">"%1$s změnil(a) téma na: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Změnili jste téma na: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s odstranil(a) téma místnosti"</string> + <string name="state_event_room_topic_removed_by_you">"Odstranili jste téma místnosti"</string> + <string name="state_event_room_unban">"%1$s zrušil(a) vykázání %2$s"</string> + <string name="state_event_room_unban_by_you">"Zrušili jste vykázání pro %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s provedl(a) neznámou změnu svého členství"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-de/translations.xml b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..0ca17bc4fe --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(Profilbild wurde auch geändert)"</string> + <string name="state_event_avatar_url_changed">"%1$s hat sein Profilbild geändert"</string> + <string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string> + <string name="state_event_display_name_changed_from">"%1$s hat seinen Anzeigenamen von %2$s in %3$s geändert"</string> + <string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s in %2$s geändert"</string> + <string name="state_event_display_name_removed">"%1$s hat seinen Anzeigenamen entfernt (es war %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (es war %1$s)"</string> + <string name="state_event_display_name_set">"%1$s hat seinen Anzeigenamen zu %2$s geändert"</string> + <string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s geändert"</string> + <string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string> + <string name="state_event_room_avatar_changed_by_you">"Du hast den Raum-Avatar geändert"</string> + <string name="state_event_room_avatar_removed">"%1$s hat das Raumbild entfernt"</string> + <string name="state_event_room_avatar_removed_by_you">"Du hast das Raumbild entfernt"</string> + <string name="state_event_room_ban">"%1$s hat %2$s gebannt"</string> + <string name="state_event_room_ban_by_you">"Du hast %1$s gebannt"</string> + <string name="state_event_room_created">"%1$s hat den Raum erstellt"</string> + <string name="state_event_room_created_by_you">"Du hast den Raum erstellt"</string> + <string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string> + <string name="state_event_room_invite_accepted">"%1$s hat die Einladung angenommen"</string> + <string name="state_event_room_invite_accepted_by_you">"Du hast die Einladung angenommen"</string> + <string name="state_event_room_invite_by_you">"Du hast %1$s eingeladen"</string> + <string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string> + <string name="state_event_room_join">"%1$s ist dem Raum beigetreten"</string> + <string name="state_event_room_join_by_you">"Du bist dem Raum beigetreten"</string> + <string name="state_event_room_knock">"%1$s hat um Beitritt gebeten"</string> + <string name="state_event_room_knock_accepted">"%1$s hat %2$s erlaubt, beizutreten"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s hat dir erlaubt beizutreten"</string> + <string name="state_event_room_knock_by_you">"Du hast um Beitritt gebeten"</string> + <string name="state_event_room_knock_denied">"%1$s hat die Beitrittsanfrage von %2$s abgelehnt"</string> + <string name="state_event_room_knock_denied_by_you">"Du hast die Beitrittsanfrage von %1$s abgelehnt"</string> + <string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string> + <string name="state_event_room_knock_retracted">"%1$s ist nicht mehr daran interessiert, beizutreten"</string> + <string name="state_event_room_knock_retracted_by_you">"Du hast deine Beitrittsanfrage zurückgezogen"</string> + <string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string> + <string name="state_event_room_leave_by_you">"Du hast den Raum verlassen"</string> + <string name="state_event_room_name_changed">"%1$s hat den Raumnamen geändert in: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Du hast den Raumnamen geändert in: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s hat den Raumnamen entfernt"</string> + <string name="state_event_room_name_removed_by_you">"Du hast den Raumnamen entfernt"</string> + <string name="state_event_room_reject">"%1$s hat die Einladung abgelehnt"</string> + <string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string> + <string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string> + <string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string> + <string name="state_event_room_third_party_invite">"%1$s hat eine Einladung an %2$s gesendet, um dem Raum beizutreten"</string> + <string name="state_event_room_third_party_invite_by_you">"Du hast eine Einladung an %1$s gesendet, um dem Raum beizutreten"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s hat die Einladung für %2$s widerrufen, dem Raum beizutreten"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Du hast die Einladung für %1$s widerrufen, dem Raum beizutreten"</string> + <string name="state_event_room_topic_changed">"%1$s hat das Thema geändert zu: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Du hast das Thema geändert zu: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s hat das Raumthema entfernt"</string> + <string name="state_event_room_topic_removed_by_you">"Du hast das Raumthema entfernt"</string> + <string name="state_event_room_unban">"%1$s hat %2$s entbannt"</string> + <string name="state_event_room_unban_by_you">"Du hast %1$s entbannt"</string> + <string name="state_event_room_unknown_membership_change">"%1$s hat eine unbekannte Änderung an seiner Mitgliedschaft vorgenommen"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..dc732d9e97 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(el avatar también cambió)"</string> + <string name="state_event_avatar_url_changed">"%1$s cambió su avatar"</string> + <string name="state_event_avatar_url_changed_by_you">"Cambiaste tu avatar"</string> + <string name="state_event_display_name_changed_from">"%1$s cambió su nombre de %2$s a %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"Cambiaste tu nombre de %1$s a %2$s"</string> + <string name="state_event_display_name_removed">"%1$s eliminó su nombre (era %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Eliminaste tu nombre (era %1$s)"</string> + <string name="state_event_display_name_set">"%1$s cambió su nombre a %2$s"</string> + <string name="state_event_display_name_set_by_you">"Cambiaste tu nombre a %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s cambió el avatar de la sala"</string> + <string name="state_event_room_avatar_changed_by_you">"Cambiaste el avatar de la sala"</string> + <string name="state_event_room_avatar_removed">"%1$s eliminó el avatar de la sala"</string> + <string name="state_event_room_avatar_removed_by_you">"Eliminaste el avatar de la sala"</string> + <string name="state_event_room_ban">"%1$s expulsó permanentemente a %2$s"</string> + <string name="state_event_room_ban_by_you">"Expulsaste permanentemente a %1$s"</string> + <string name="state_event_room_created">"%1$s creó la sala"</string> + <string name="state_event_room_created_by_you">"Tú creaste la sala"</string> + <string name="state_event_room_invite">"%1$s invitó a %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s aceptó la invitación"</string> + <string name="state_event_room_invite_accepted_by_you">"Aceptaste la invitación"</string> + <string name="state_event_room_invite_by_you">"Invitaste a %1$s"</string> + <string name="state_event_room_invite_you">"%1$s te invitó."</string> + <string name="state_event_room_join">"%1$s se unió a la sala"</string> + <string name="state_event_room_join_by_you">"Te uniste a la sala"</string> + <string name="state_event_room_knock">"%1$s solicitó unirse"</string> + <string name="state_event_room_knock_accepted">"%1$s permitió que %2$s se uniera"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s te permitió unirte"</string> + <string name="state_event_room_knock_by_you">"Solicitaste unirte"</string> + <string name="state_event_room_knock_denied">"%1$s rechazó la solicitud de %2$s para unirse"</string> + <string name="state_event_room_knock_denied_by_you">"Rechazaste la solicitud de %1$s para unirte"</string> + <string name="state_event_room_knock_denied_you">"%1$s rechazó su solicitud para unirte"</string> + <string name="state_event_room_knock_retracted">"%1$s ya no está interesado en unirse"</string> + <string name="state_event_room_knock_retracted_by_you">"Cancelaste tu solicitud de unirte"</string> + <string name="state_event_room_leave">"%1$s salió de la sala"</string> + <string name="state_event_room_leave_by_you">"Saliste de la sala"</string> + <string name="state_event_room_name_changed">"%1$s cambió el nombre de la sala a: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Cambiaste el nombre de la sala a: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s eliminó el nombre de la sala"</string> + <string name="state_event_room_name_removed_by_you">"Eliminaste el nombre de la sala"</string> + <string name="state_event_room_reject">"%1$s rechazó la invitación"</string> + <string name="state_event_room_reject_by_you">"Rechazaste la invitación"</string> + <string name="state_event_room_remove">"%1$s echó a %2$s"</string> + <string name="state_event_room_remove_by_you">"Echaste a %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s envió una invitación a %2$s para unirse a la sala"</string> + <string name="state_event_room_third_party_invite_by_you">"Enviaste una invitación a %1$s para unirse a la sala"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s revocó la invitación a %2$s para unirse a la sala"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Revocaste la invitación de %1$s para unirse a la sala"</string> + <string name="state_event_room_topic_changed">"%1$s cambió el tema a: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Cambiaste el tema a: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s eliminó el tema de la sala"</string> + <string name="state_event_room_topic_removed_by_you">"Eliminaste el tema de la sala"</string> + <string name="state_event_room_unban">"%1$s readmitió a %2$s"</string> + <string name="state_event_room_unban_by_you">"Readmitiste a %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s realizó un cambio desconocido en su membresía"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..e7e07c0852 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(l\'avatar a aussi été modifié)"</string> + <string name="state_event_avatar_url_changed">"%1$s a changé son avatar"</string> + <string name="state_event_avatar_url_changed_by_you">"Vous avez changé d\'avatar"</string> + <string name="state_event_display_name_changed_from">"%1$s a changé son nom d\'affichage de %2$s à %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"Vous avez changé votre nom d\'affichage de %1$s à %2$s"</string> + <string name="state_event_display_name_removed">"%1$s a supprimé son nom d\'affichage (il s\'agissait de %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Vous avez supprimé votre nom d\'affichage (il s\'agissait de %1$s)"</string> + <string name="state_event_display_name_set">"%1$s a défini son nom d\'affichage en tant que %2$s"</string> + <string name="state_event_display_name_set_by_you">"Vous avez défini votre nom d\'affichage en tant que %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s a changé l\'avatar du salon"</string> + <string name="state_event_room_avatar_changed_by_you">"Vous avez changé l\'avatar du salon"</string> + <string name="state_event_room_avatar_removed">"%1$s a supprimé l\'avatar du salon"</string> + <string name="state_event_room_avatar_removed_by_you">"Vous avez supprimé l\'avatar du salon"</string> + <string name="state_event_room_ban">"%1$s a banni %2$s"</string> + <string name="state_event_room_ban_by_you">"Vous avez banni %1$s"</string> + <string name="state_event_room_created">"%1$s a créé le salon"</string> + <string name="state_event_room_created_by_you">"Vous avez créé le salon"</string> + <string name="state_event_room_invite">"%1$s a invité %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s a accepté l\'invitation"</string> + <string name="state_event_room_invite_accepted_by_you">"Vous avez accepté l\'invitation"</string> + <string name="state_event_room_invite_by_you">"Vous avez invité %1$s"</string> + <string name="state_event_room_invite_you">"%1$s vous a invité."</string> + <string name="state_event_room_join">"%1$s a rejoint le salon"</string> + <string name="state_event_room_join_by_you">"Vous avez rejoint le salon"</string> + <string name="state_event_room_knock">"%1$s a demandé à rejoindre"</string> + <string name="state_event_room_knock_accepted">"%1$s a autorisé %2$s à rejoindre"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s vous a autorisé à rejoindre"</string> + <string name="state_event_room_knock_by_you">"Vous avez demandé à rejoindre"</string> + <string name="state_event_room_knock_denied">"%1$s a rejeté la demande d\'adhésion de %2$s"</string> + <string name="state_event_room_knock_denied_by_you">"Vous avez rejeté la demande d\'adhésion de %1$s"</string> + <string name="state_event_room_knock_denied_you">"%1$s a rejeté votre demande d\'adhésion"</string> + <string name="state_event_room_knock_retracted">"%1$s n’est plus intéressé à rejoindre"</string> + <string name="state_event_room_knock_retracted_by_you">"Vous avez annulé votre demande d\'adhésion"</string> + <string name="state_event_room_leave">"%1$s a quitté le salon"</string> + <string name="state_event_room_leave_by_you">"Vous avez quitté le salon"</string> + <string name="state_event_room_name_changed">"%1$s a changé le nom du salon en : %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Vous avez changé le nom du salon en : %1$s"</string> + <string name="state_event_room_name_removed">"%1$s a supprimé le nom du salon"</string> + <string name="state_event_room_name_removed_by_you">"Vous avez supprimé le nom du salon"</string> + <string name="state_event_room_reject">"%1$s a rejeté l\'invitation"</string> + <string name="state_event_room_reject_by_you">"Vous avez refusé l\'invitation"</string> + <string name="state_event_room_remove">"%1$s a supprimé %2$s"</string> + <string name="state_event_room_remove_by_you">"Vous avez supprimé %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s a envoyé une invitation à %2$s à rejoindre le salon"</string> + <string name="state_event_room_third_party_invite_by_you">"Vous avez envoyé une invitation à %1$s pour rejoindre le salon"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s a révoqué l\'invitation de %2$s à rejoindre le salon"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Vous avez révoqué l\'invitation de %1$s à rejoindre le salon"</string> + <string name="state_event_room_topic_changed">"%1$s a changé le sujet en : %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Vous avez changé le sujet en : %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s a supprimé le sujet du salon"</string> + <string name="state_event_room_topic_removed_by_you">"Vous avez supprimé le sujet du salon"</string> + <string name="state_event_room_unban">"%1$s a débanni %2$s"</string> + <string name="state_event_room_unban_by_you">"Vous avez débanni %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s a apporté une modification inconnue à son adhésion"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..2e2e914dfe --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(anche l\'avatar è stato cambiato)"</string> + <string name="state_event_avatar_url_changed">"%1$s ha cambiato il proprio avatar"</string> + <string name="state_event_avatar_url_changed_by_you">"Hai cambiato il tuo avatar"</string> + <string name="state_event_display_name_changed_from">"%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"Hai cambiato il tuo nome visualizzato da %1$s a %2$s"</string> + <string name="state_event_display_name_removed">"%1$s ha rimosso il proprio nome visualizzato (era %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Hai rimosso il tuo nome visualizzato (era %1$s)"</string> + <string name="state_event_display_name_set">"%1$s ha impostato il proprio nome visualizzato su %2$s"</string> + <string name="state_event_display_name_set_by_you">"Hai impostato il tuo nome visualizzato su %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s ha cambiato l\'avatar della stanza"</string> + <string name="state_event_room_avatar_changed_by_you">"Hai cambiato l\'avatar della stanza"</string> + <string name="state_event_room_avatar_removed">"%1$s ha rimosso l\'avatar della stanza"</string> + <string name="state_event_room_avatar_removed_by_you">"Hai rimosso l\'avatar della stanza"</string> + <string name="state_event_room_ban">"%1$s ha rimosso %2$s"</string> + <string name="state_event_room_ban_by_you">"Hai rimosso %1$s"</string> + <string name="state_event_room_created">"%1$s ha creato la stanza"</string> + <string name="state_event_room_created_by_you">"Hai creato la stanza"</string> + <string name="state_event_room_invite">"%1$s ha invitato %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s ha accettato l\'invito"</string> + <string name="state_event_room_invite_accepted_by_you">"Hai accettato l\'invito"</string> + <string name="state_event_room_invite_by_you">"Hai invitato %1$s"</string> + <string name="state_event_room_invite_you">"%1$s ti ha invitato"</string> + <string name="state_event_room_join">"%1$s si è unito alla stanza"</string> + <string name="state_event_room_join_by_you">"Ti sei unito alla stanza"</string> + <string name="state_event_room_knock">"%1$s ha chiesto di unirsi"</string> + <string name="state_event_room_knock_accepted">"%1$s ha permesso a %2$s di unirsi"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s ti ha permesso di unirti"</string> + <string name="state_event_room_knock_by_you">"Hai richiesto di unirti"</string> + <string name="state_event_room_knock_denied">"%1$s ha rifiutato la richiesta di unirsi di %2$s"</string> + <string name="state_event_room_knock_denied_by_you">"Hai rifiutato la richiesta di unirsi di %1$s"</string> + <string name="state_event_room_knock_denied_you">"%1$s ha rifiutato la tua richiesta di unirti"</string> + <string name="state_event_room_knock_retracted">"%1$s non è più interessato a partecipare"</string> + <string name="state_event_room_knock_retracted_by_you">"Hai annullato la tua richiesta di unirti"</string> + <string name="state_event_room_leave">"%1$s ha lasciato la stanza"</string> + <string name="state_event_room_leave_by_you">"Hai lasciato la stanza"</string> + <string name="state_event_room_name_changed">"%1$s ha cambiato il nome della stanza in: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Hai cambiato il nome della stanza in: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s ha rimosso il nome della stanza"</string> + <string name="state_event_room_name_removed_by_you">"Hai rimosso il nome della stanza"</string> + <string name="state_event_room_reject">"%1$s ha rifiutato l\'invito"</string> + <string name="state_event_room_reject_by_you">"Hai rifiutato l\'invito"</string> + <string name="state_event_room_remove">"%1$s ha rimosso %2$s"</string> + <string name="state_event_room_remove_by_you">"Hai rimosso %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s ha inviato un invito a %2$s per unirsi alla stanza"</string> + <string name="state_event_room_third_party_invite_by_you">"Hai inviato un invito a %1$s per unirsi alla stanza"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza."</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Hai revocato l\'invito a %1$s a universi alla stanza"</string> + <string name="state_event_room_topic_changed">"%1$s ha cambiato l\'oggetto in: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Hai cambiato l\'oggetto in: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s ha rimosso l\'oggetto della stanza"</string> + <string name="state_event_room_topic_removed_by_you">"Hai rimosso l\'oggetto della stanza"</string> + <string name="state_event_room_unban">"%1$s ha sbloccato %2$s"</string> + <string name="state_event_room_unban_by_you">"Hai sbloccato %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s ha apportato una modifica sconosciuta alla propria iscrizione"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..2586ad3cd2 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(s-a schimbat si avatarul)"</string> + <string name="state_event_avatar_url_changed">"%1$s și-a schimbat avatarul"</string> + <string name="state_event_avatar_url_changed_by_you">"V-ați schimbat avatarul"</string> + <string name="state_event_display_name_changed_from">"%1$s și-a schimbat numele din %2$s în %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"V-ați schimbat numele din %1$s în %2$s"</string> + <string name="state_event_display_name_removed">"%1$s și-a sters numele (era %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"V-ați sters numele (era %1$s)"</string> + <string name="state_event_display_name_set">"%1$s și-a schimbat numele %2$s"</string> + <string name="state_event_display_name_set_by_you">"V-ați schimbat numele în %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s a schimbat avatarul camerei"</string> + <string name="state_event_room_avatar_changed_by_you">"Ați schimbat avatarul camerei"</string> + <string name="state_event_room_avatar_removed">"%1$s a șters avatarul camerei"</string> + <string name="state_event_room_avatar_removed_by_you">"Ați șters avatarul camerei"</string> + <string name="state_event_room_ban">"%1$s a adăugat o interdicție pentru %2$s"</string> + <string name="state_event_room_ban_by_you">"Ați adăugat o interdicție pentru %1$s"</string> + <string name="state_event_room_created">"%1$s a creat camera"</string> + <string name="state_event_room_created_by_you">"Ați creat camera"</string> + <string name="state_event_room_invite">"%1$s l-a invitat pe %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s a acceptat invitația"</string> + <string name="state_event_room_invite_accepted_by_you">"Ați acceptat invitația"</string> + <string name="state_event_room_invite_by_you">"L-ați invitat pe %1$s"</string> + <string name="state_event_room_invite_you">"%1$s v-a invitat"</string> + <string name="state_event_room_join">"%1$s a intrat în cameră"</string> + <string name="state_event_room_join_by_you">"Ați intrat în cameră"</string> + <string name="state_event_room_knock">"%1$s a solicitat să se alăture camerei"</string> + <string name="state_event_room_knock_accepted">"%1$s i-a permis lui %2$s să se alăture camerei"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s v-a permis să vă alăturați camerei"</string> + <string name="state_event_room_knock_by_you">"Ați solicitat să vă alăturați camerei"</string> + <string name="state_event_room_knock_denied">"%1$s a respins solicitarea de alăturare a lui %2$s"</string> + <string name="state_event_room_knock_denied_by_you">"Ați respins solicitarea de alăturare a lui %1$s"</string> + <string name="state_event_room_knock_denied_you">"%1$s a respins cererea dumneavoastră de alăturare"</string> + <string name="state_event_room_knock_retracted">"%1$s nu mai este interesat să se alăture camerei"</string> + <string name="state_event_room_knock_retracted_by_you">"Ați anulat cererea de alăturare"</string> + <string name="state_event_room_leave">"%1$s a părăsit camera"</string> + <string name="state_event_room_leave_by_you">"Ați părăsit camera"</string> + <string name="state_event_room_name_changed">"%1$s a schimbat numele camerei în: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Ați schimbat numele camerei în: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s a sters numele camerei"</string> + <string name="state_event_room_name_removed_by_you">"Ați șters numele camerei"</string> + <string name="state_event_room_reject">"%1$s a respins invitația"</string> + <string name="state_event_room_reject_by_you">"Ați respins invitația"</string> + <string name="state_event_room_remove">"%1$s l-a îndepărtat pe %2$s"</string> + <string name="state_event_room_remove_by_you">"L-ați îndepărtat pe %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s a trimis o invitație către %2$s pentru a se alătura camerei"</string> + <string name="state_event_room_third_party_invite_by_you">"Ați trimis o invitație către %1$s pentru a se alătura camerei"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s a revocat invitația pentru %2$s de a se alătura camerei"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Ați revocat invitația pentru %1$s de a se alătura camerei"</string> + <string name="state_event_room_topic_changed">"%1$s a schimbat subiectul în: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Ați schimbat subiectul în: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s a șters subiectul camerei"</string> + <string name="state_event_room_topic_removed_by_you">"Ați șters subiectul camerei"</string> + <string name="state_event_room_unban">"%1$s a anulat interdicția pentru %2$s"</string> + <string name="state_event_room_unban_by_you">"Ați anulat interdicția pentru %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s a făcut o modificare necunoscută asupra calității sale de membru"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..13722a3318 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(obrázok bol tiež zmenený)"</string> + <string name="state_event_avatar_url_changed">"%1$s zmenili svoj obrázok"</string> + <string name="state_event_avatar_url_changed_by_you">"Zmenili ste svoj obrázok"</string> + <string name="state_event_display_name_changed_from">"%1$s zmenili svoje zobrazované meno z %2$s na %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"Zmenili ste si zobrazované meno z %1$s na %2$s"</string> + <string name="state_event_display_name_removed">"%1$s odstránili svoje zobrazované meno (predtým bolo %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"Odstránili ste svoje zobrazované meno (predtým bolo %1$s)"</string> + <string name="state_event_display_name_set">"%1$s nastavili svoje zobrazované meno na %2$s"</string> + <string name="state_event_display_name_set_by_you">"Svoje zobrazované meno ste nastavili na %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s zmenil/a obrázok miestnosti"</string> + <string name="state_event_room_avatar_changed_by_you">"Zmenili ste obrázok miestnosti"</string> + <string name="state_event_room_avatar_removed">"%1$s odstránil/a obrázok miestnosti"</string> + <string name="state_event_room_avatar_removed_by_you">"Odstránili ste obrázok miestnosti"</string> + <string name="state_event_room_ban">"%1$s zakázal/a používateľa %2$s"</string> + <string name="state_event_room_ban_by_you">"Zakázali ste používateľa %1$s"</string> + <string name="state_event_room_created">"%1$s vytvoril/a miestnosť"</string> + <string name="state_event_room_created_by_you">"Vytvorili ste miestnosť"</string> + <string name="state_event_room_invite">"%1$s pozval/a používateľa %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s prijal/a pozvanie"</string> + <string name="state_event_room_invite_accepted_by_you">"Prijali ste pozvánku"</string> + <string name="state_event_room_invite_by_you">"Pozvali ste používateľa %1$s"</string> + <string name="state_event_room_invite_you">"%1$s vás pozval/a"</string> + <string name="state_event_room_join">"%1$s sa pripojil/a do miestnosti"</string> + <string name="state_event_room_join_by_you">"Vstúpili ste do miestnosti"</string> + <string name="state_event_room_knock">"%1$s požiadal o pripojenie"</string> + <string name="state_event_room_knock_accepted">"%1$s umožnil/a používateľovi %2$s pripojiť sa"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s vám umožnil/a pripojiť sa"</string> + <string name="state_event_room_knock_by_you">"Požiadali ste o pripojenie"</string> + <string name="state_event_room_knock_denied">"%1$s odmietol/a žiadosť používateľa %2$s o vstup"</string> + <string name="state_event_room_knock_denied_by_you">"Odmietli ste žiadosť používateľa %1$s o pripojenie"</string> + <string name="state_event_room_knock_denied_you">"%1$s zamietol vašu žiadosť o pripojenie"</string> + <string name="state_event_room_knock_retracted">"%1$s už nemá záujem o vstup"</string> + <string name="state_event_room_knock_retracted_by_you">"Zrušili ste svoju žiadosť o pripojenie"</string> + <string name="state_event_room_leave">"%1$s opustil/a miestnosť"</string> + <string name="state_event_room_leave_by_you">"Opustili ste miestnosť"</string> + <string name="state_event_room_name_changed">"%1$s zmenil/a názov miestnosti na: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"Zmenili ste názov miestnosti na: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s odstránil/a názov miestnosti"</string> + <string name="state_event_room_name_removed_by_you">"Odstránili ste názov miestnosti"</string> + <string name="state_event_room_reject">"%1$s odmietol/a pozvánku"</string> + <string name="state_event_room_reject_by_you">"Odmietli ste pozvánku"</string> + <string name="state_event_room_remove">"%1$s odstránil/a %2$s"</string> + <string name="state_event_room_remove_by_you">"Odstránili ste %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s poslal/a pozvánku používateľovi %2$s, aby sa pripojil k miestnosti"</string> + <string name="state_event_room_third_party_invite_by_you">"Poslali ste pozvánku používateľovi %1$s, aby sa pripojil do miestnosti"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s zrušil/a pozvánku pre používateľa %2$s na vstup do miestnosti"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"Zrušili ste pozvánku pre používateľa %1$s na vstup do miestnosti"</string> + <string name="state_event_room_topic_changed">"%1$s zmenil/a tému na: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"Zmenili ste tému na: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s odstránil/a tému miestnosti"</string> + <string name="state_event_room_topic_removed_by_you">"Odstránili ste tému miestnosti"</string> + <string name="state_event_room_unban">"%1$s zrušil/a zákaz pre %2$s"</string> + <string name="state_event_room_unban_by_you">"Zrušili ste zákaz pre %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s urobil/a neznámu zmenu svojho členstva"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/main/res/values/localazy.xml b/libraries/eventformatter/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..03a13bd29b --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="state_event_avatar_changed_too">"(avatar was changed too)"</string> + <string name="state_event_avatar_url_changed">"%1$s changed their avatar"</string> + <string name="state_event_avatar_url_changed_by_you">"You changed your avatar"</string> + <string name="state_event_display_name_changed_from">"%1$s changed their display name from %2$s to %3$s"</string> + <string name="state_event_display_name_changed_from_by_you">"You changed your display name from %1$s to %2$s"</string> + <string name="state_event_display_name_removed">"%1$s removed their display name (it was %2$s)"</string> + <string name="state_event_display_name_removed_by_you">"You removed your display name (it was %1$s)"</string> + <string name="state_event_display_name_set">"%1$s set their display name to %2$s"</string> + <string name="state_event_display_name_set_by_you">"You set your display name to %1$s"</string> + <string name="state_event_room_avatar_changed">"%1$s changed the room avatar"</string> + <string name="state_event_room_avatar_changed_by_you">"You changed the room avatar"</string> + <string name="state_event_room_avatar_removed">"%1$s removed the room avatar"</string> + <string name="state_event_room_avatar_removed_by_you">"You removed the room avatar"</string> + <string name="state_event_room_ban">"%1$s banned %2$s"</string> + <string name="state_event_room_ban_by_you">"You banned %1$s"</string> + <string name="state_event_room_created">"%1$s created the room"</string> + <string name="state_event_room_created_by_you">"You created the room"</string> + <string name="state_event_room_invite">"%1$s invited %2$s"</string> + <string name="state_event_room_invite_accepted">"%1$s accepted the invite"</string> + <string name="state_event_room_invite_accepted_by_you">"You accepted the invite"</string> + <string name="state_event_room_invite_by_you">"You invited %1$s"</string> + <string name="state_event_room_invite_you">"%1$s invited you"</string> + <string name="state_event_room_join">"%1$s joined the room"</string> + <string name="state_event_room_join_by_you">"You joined the room"</string> + <string name="state_event_room_knock">"%1$s requested to join"</string> + <string name="state_event_room_knock_accepted">"%1$s allowed %2$s to join"</string> + <string name="state_event_room_knock_accepted_by_you">"%1$s allowed you to join"</string> + <string name="state_event_room_knock_by_you">"You requested to join"</string> + <string name="state_event_room_knock_denied">"%1$s rejected %2$s\'s request to join"</string> + <string name="state_event_room_knock_denied_by_you">"You rejected %1$s\'s request to join"</string> + <string name="state_event_room_knock_denied_you">"%1$s rejected your request to join"</string> + <string name="state_event_room_knock_retracted">"%1$s is no longer interested in joining"</string> + <string name="state_event_room_knock_retracted_by_you">"You cancelled your request to join"</string> + <string name="state_event_room_leave">"%1$s left the room"</string> + <string name="state_event_room_leave_by_you">"You left the room"</string> + <string name="state_event_room_name_changed">"%1$s changed the room name to: %2$s"</string> + <string name="state_event_room_name_changed_by_you">"You changed the room name to: %1$s"</string> + <string name="state_event_room_name_removed">"%1$s removed the room name"</string> + <string name="state_event_room_name_removed_by_you">"You removed the room name"</string> + <string name="state_event_room_reject">"%1$s rejected the invitation"</string> + <string name="state_event_room_reject_by_you">"You rejected the invitation"</string> + <string name="state_event_room_remove">"%1$s removed %2$s"</string> + <string name="state_event_room_remove_by_you">"You removed %1$s"</string> + <string name="state_event_room_third_party_invite">"%1$s sent an invitation to %2$s to join the room"</string> + <string name="state_event_room_third_party_invite_by_you">"You sent an invitation to %1$s to join the room"</string> + <string name="state_event_room_third_party_revoked_invite">"%1$s revoked the invitation for %2$s to join the room"</string> + <string name="state_event_room_third_party_revoked_invite_by_you">"You revoked the invitation for %1$s to join the room"</string> + <string name="state_event_room_topic_changed">"%1$s changed the topic to: %2$s"</string> + <string name="state_event_room_topic_changed_by_you">"You changed the topic to: %1$s"</string> + <string name="state_event_room_topic_removed">"%1$s removed the room topic"</string> + <string name="state_event_room_topic_removed_by_you">"You removed the room topic"</string> + <string name="state_event_room_unban">"%1$s unbanned %2$s"</string> + <string name="state_event_room_unban_by_you">"You unbanned %1$s"</string> + <string name="state_event_room_unknown_membership_change">"%1$s made an unknown change to their membership"</string> +</resources> diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt new file mode 100644 index 0000000000..494c63784d --- /dev/null +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTests.kt @@ -0,0 +1,772 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.impl + +import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class DefaultRoomLastMessageFormatterTests { + + private lateinit var context: Context + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var formatter: DefaultRoomLastMessageFormatter + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() as Context + fakeMatrixClient = FakeMatrixClient() + val stringProvider = AndroidStringProvider(context.resources) + formatter = DefaultRoomLastMessageFormatter( + sp = AndroidStringProvider(context.resources), + matrixClient = fakeMatrixClient, + roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider) + ) + } + + @Test + @Config(qualifiers = "en") + fun `Redacted content`() { + val expected = "Message removed" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createRoomEvent(false, senderName, RedactedContent) + val result = formatter.format(message, isDm) + if (isDm) { + Truth.assertThat(result).isEqualTo(expected) + } else { + Truth.assertThat(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertThat(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + + @Test + @Config(qualifiers = "en") + fun `Sticker content`() { + val body = "body" + val info = ImageInfo(null, null, null, null, null, null, null) + val message = createRoomEvent(false, null, StickerContent(body, info, "url")) + val result = formatter.format(message, false) + Truth.assertThat(result).isEqualTo(body) + } + + @Test + @Config(qualifiers = "en") + fun `Unable to decrypt content`() { + val expected = "Decryption error" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val result = formatter.format(message, isDm) + if (isDm) { + Truth.assertThat(result).isEqualTo(expected) + } else { + Truth.assertThat(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertThat(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + + @Test + @Config(qualifiers = "en") + fun `FailedToParseMessageLike, FailedToParseState & Unknown content`() { + val expected = "Unsupported event" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + sequenceOf( + FailedToParseMessageLikeContent("", ""), + FailedToParseStateContent("", "", ""), + UnknownContent, + ).forEach { type -> + val message = createRoomEvent(false, senderName, type) + val result = formatter.format(message, isDm) + if (isDm) { + Truth.assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected) + } else { + Truth.assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java) + Truth.assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + } + + // region Message contents + + @Test + @Config(qualifiers = "en") + fun `Message contents`() { + val body = "Shared body" + fun createMessageContent(type: MessageType): MessageContent { + return MessageContent(body, null, false, type) + } + + val sharedContentMessagesTypes = arrayOf( + TextMessageType(body, null), + VideoMessageType(body, MediaSource("url"), null), + AudioMessageType(body, MediaSource("url"), null), + ImageMessageType(body, MediaSource("url"), null), + FileMessageType(body, MediaSource("url"), null), + LocationMessageType(body, "geo:1,2", null), + NoticeMessageType(body, null), + EmoteMessageType(body, null), + ) + val senderName = "Someone" + val resultsInRoom = mutableListOf<Pair<MessageType, CharSequence?>>() + val resultsInDm = mutableListOf<Pair<MessageType, CharSequence?>>() + + // Create messages for all types in DM and Room mode + sequenceOf(false, true).forEach { isDm -> + sharedContentMessagesTypes.forEach { type -> + val content = createMessageContent(type) + val message = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(message, isDmRoom = isDm) + if (isDm) { + resultsInDm.add(type to result) + } else { + resultsInRoom.add(type to result) + } + } + val unknownMessage = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = createMessageContent(UnknownMessageType)) + val result = UnknownMessageType to formatter.format(unknownMessage, isDmRoom = isDm) + if (isDm) { + resultsInDm.add(result) + } else { + resultsInRoom.add(result) + } + } + + // Verify results of DM mode + for ((type, result) in resultsInDm) { + val expectedResult = when (type) { + is VideoMessageType -> "Video" + is AudioMessageType -> "Audio" + is ImageMessageType -> "Image" + is FileMessageType -> "File" + is LocationMessageType -> "Shared location" + is EmoteMessageType -> "- $senderName ${type.body}" + is TextMessageType, is NoticeMessageType -> body + UnknownMessageType -> "Unsupported event" + } + Truth.assertWithMessage("$type was not properly handled").that(result).isEqualTo(expectedResult) + } + + // Verify results of Room mode + for ((type, result) in resultsInRoom) { + val string = result.toString() + val expectedResult = when (type) { + is VideoMessageType -> "$senderName: Video" + is AudioMessageType -> "$senderName: Audio" + is ImageMessageType -> "$senderName: Image" + is FileMessageType -> "$senderName: File" + is LocationMessageType -> "$senderName: Shared location" + is EmoteMessageType -> "- $senderName ${type.body}" + is TextMessageType, is NoticeMessageType -> "$senderName: $body" + UnknownMessageType -> "$senderName: Unsupported event" + } + val shouldCreateAnnotatedString = when (type) { + is VideoMessageType -> true + is AudioMessageType -> true + is ImageMessageType -> true + is FileMessageType -> true + is LocationMessageType -> false + is EmoteMessageType -> false + is TextMessageType, is NoticeMessageType -> true + UnknownMessageType -> true + } + if (shouldCreateAnnotatedString) { + Truth.assertWithMessage("$type doesn't produce an AnnotatedString") + .that(result) + .isInstanceOf(AnnotatedString::class.java) + } + Truth.assertWithMessage("$type was not properly handled").that(string).isEqualTo(expectedResult) + } + } + + // endregion + + // region Membership change + + @Test + @Config(qualifiers = "en") + fun `Membership change - joined`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.JOINED) + + val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youJoinedRoom = formatter.format(youJoinedRoomEvent, false) + Truth.assertThat(youJoinedRoom).isEqualTo("You joined the room") + + val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent, false) + Truth.assertThat(someoneJoinedRoom).isEqualTo("${someoneContent.userId} joined the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - left`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.LEFT) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.LEFT) + + val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youLeftRoom = formatter.format(youLeftRoomEvent, false) + Truth.assertThat(youLeftRoom).isEqualTo("You left the room") + + val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneLeftRoom = formatter.format(someoneLeftRoomEvent, false) + Truth.assertThat(someoneLeftRoom).isEqualTo("${someoneContent.userId} left the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - banned`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) + val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) + val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) + + val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youBanned = formatter.format(youBannedEvent, false) + Truth.assertThat(youBanned).isEqualTo("You banned ${youContent.userId}") + + val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) + val youKickedBanned = formatter.format(youKickBannedEvent, false) + Truth.assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId}") + + val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneBanned = formatter.format(someoneBannedEvent, false) + Truth.assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + + val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) + val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) + Truth.assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - unban`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) + + val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youUnbanned = formatter.format(youUnbannedEvent, false) + Truth.assertThat(youUnbanned).isEqualTo("You unbanned ${youContent.userId}") + + val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneUnbanned = formatter.format(someoneUnbannedEvent, false) + Truth.assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - kicked`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) + + val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKicked = formatter.format(youKickedEvent, false) + Truth.assertThat(youKicked).isEqualTo("You removed ${youContent.userId}") + + val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKicked = formatter.format(someoneKickedEvent, false) + Truth.assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invited`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITED) + + val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val youWereInvited = formatter.format(youWereInvitedEvent, false) + Truth.assertThat(youWereInvited).isEqualTo("$otherName invited you") + + val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youInvited = formatter.format(youInvitedEvent, false) + Truth.assertThat(youInvited).isEqualTo("You invited ${someoneContent.userId}") + + val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneInvited = formatter.format(someoneInvitedEvent, false) + Truth.assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId}") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation accepted`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_ACCEPTED) + + val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youAcceptedInvite = formatter.format(youAcceptedInviteEvent, false) + Truth.assertThat(youAcceptedInvite).isEqualTo("You accepted the invite") + + val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent, false) + Truth.assertThat(someoneAcceptedInvite).isEqualTo("${someoneContent.userId} accepted the invite") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation rejected`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_REJECTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REJECTED) + + val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRejectedInvite = formatter.format(youRejectedInviteEvent, false) + Truth.assertThat(youRejectedInvite).isEqualTo("You rejected the invitation") + + val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent, false) + Truth.assertThat(someoneRejectedInvite).isEqualTo("${someoneContent.userId} rejected the invitation") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation revoked`() { + val otherName = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REVOKED) + + val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youRevokedInvite = formatter.format(youRevokedInviteEvent, false) + Truth.assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for ${someoneContent.userId} to join the room") + + val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent, false) + Truth.assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for ${someoneContent.userId} to join the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knocked`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCKED) + + val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKnocked = formatter.format(youKnockedEvent, false) + Truth.assertThat(youKnocked).isEqualTo("You requested to join") + + val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKnocked = formatter.format(someoneKnockedEvent, false) + Truth.assertThat(someoneKnocked).isEqualTo("${someoneContent.userId} requested to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock accepted`() { + val otherName = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_ACCEPTED) + + val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false) + Truth.assertThat(youAcceptedKnock).isEqualTo("${someoneContent.userId} allowed you to join") + + val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false) + Truth.assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed ${someoneContent.userId} to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock retracted`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_RETRACTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_RETRACTED) + + val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRetractedKnock = formatter.format(youRetractedKnockEvent, false) + Truth.assertThat(youRetractedKnock).isEqualTo("You cancelled your request to join") + + val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent, false) + Truth.assertThat(someoneRetractedKnock).isEqualTo("${someoneContent.userId} is no longer interested in joining") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock denied`() { + val otherName = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_DENIED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_DENIED) + + val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youDeniedKnock = formatter.format(youDeniedKnockEvent, false) + Truth.assertThat(youDeniedKnock).isEqualTo("You rejected ${someoneContent.userId}'s request to join") + + val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent, false) + Truth.assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected ${someoneContent.userId}'s request to join") + + val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent, false) + Truth.assertThat(someoneDeniedYourKnock).isEqualTo("$otherName rejected your request to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - others`() { + val otherChanges = arrayOf(MembershipChange.NONE, MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED) + + val results = otherChanges.map { change -> + val content = RoomMembershipContent(A_USER_ID, change) + val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(event, false) + change to result + } + val expected = otherChanges.map { it to null } + Truth.assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Room State + + @Test + @Config(qualifiers = "en") + fun `Room state change - avatar`() { + val otherName = "Someone" + val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar")) + val removedContent = StateContent("", OtherState.RoomAvatar(null)) + + val youChangedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomAvatar = formatter.format(youChangedRoomAvatarEvent, false) + Truth.assertThat(youChangedRoomAvatar).isEqualTo("You changed the room avatar") + + val someoneChangedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomAvatar = formatter.format(someoneChangedRoomAvatarEvent, false) + Truth.assertThat(someoneChangedRoomAvatar).isEqualTo("$otherName changed the room avatar") + + val youRemovedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomAvatar = formatter.format(youRemovedRoomAvatarEvent, false) + Truth.assertThat(youRemovedRoomAvatar).isEqualTo("You removed the room avatar") + + val someoneRemovedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomAvatar = formatter.format(someoneRemovedRoomAvatarEvent, false) + Truth.assertThat(someoneRemovedRoomAvatar).isEqualTo("$otherName removed the room avatar") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - create`() { + val otherName = "Someone" + val content = StateContent("", OtherState.RoomCreate) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) + Truth.assertThat(youCreatedRoom).isEqualTo("You created the room") + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) + Truth.assertThat(someoneCreatedRoom).isEqualTo("$otherName created the room") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - encryption`() { + val otherName = "Someone" + val content = StateContent("", OtherState.RoomEncryption) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) + Truth.assertThat(youCreatedRoom).isEqualTo("Encryption enabled") + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) + Truth.assertThat(someoneCreatedRoom).isEqualTo("Encryption enabled") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room name`() { + val otherName = "Someone" + val newName = "New name" + val changedContent = StateContent("", OtherState.RoomName(newName)) + val removedContent = StateContent("", OtherState.RoomName(null)) + + val youChangedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomName = formatter.format(youChangedRoomNameEvent, false) + Truth.assertThat(youChangedRoomName).isEqualTo("You changed the room name to: $newName") + + val someoneChangedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomName = formatter.format(someoneChangedRoomNameEvent, false) + Truth.assertThat(someoneChangedRoomName).isEqualTo("$otherName changed the room name to: $newName") + + val youRemovedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomName = formatter.format(youRemovedRoomNameEvent, false) + Truth.assertThat(youRemovedRoomName).isEqualTo("You removed the room name") + + val someoneRemovedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomName = formatter.format(someoneRemovedRoomNameEvent, false) + Truth.assertThat(someoneRemovedRoomName).isEqualTo("$otherName removed the room name") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - third party invite`() { + val otherName = "Someone" + val inviteeName = "Alice" + val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName)) + val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) + + val youInvitedSomeoneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youInvitedSomeone = formatter.format(youInvitedSomeoneEvent, false) + Truth.assertThat(youInvitedSomeone).isEqualTo("You sent an invitation to $inviteeName to join the room") + + val someoneInvitedSomeoneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneInvitedSomeone = formatter.format(someoneInvitedSomeoneEvent, false) + Truth.assertThat(someoneInvitedSomeone).isEqualTo("$otherName sent an invitation to $inviteeName to join the room") + + val youInvitedNoOneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youInvitedNoOne = formatter.format(youInvitedNoOneEvent, false) + Truth.assertThat(youInvitedNoOne).isNull() + + val someoneInvitedNoOneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneInvitedNoOne = formatter.format(someoneInvitedNoOneEvent, false) + Truth.assertThat(someoneInvitedNoOne).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room topic`() { + val otherName = "Someone" + val roomTopic = "New topic" + val changedContent = StateContent("", OtherState.RoomTopic(roomTopic)) + val removedContent = StateContent("", OtherState.RoomTopic(null)) + + val youChangedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomTopic = formatter.format(youChangedRoomTopicEvent, false) + Truth.assertThat(youChangedRoomTopic).isEqualTo("You changed the topic to: $roomTopic") + + val someoneChangedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomTopic = formatter.format(someoneChangedRoomTopicEvent, false) + Truth.assertThat(someoneChangedRoomTopic).isEqualTo("$otherName changed the topic to: $roomTopic") + + val youRemovedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomTopic = formatter.format(youRemovedRoomTopicEvent, false) + Truth.assertThat(youRemovedRoomTopic).isEqualTo("You removed the room topic") + + val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent, false) + Truth.assertThat(someoneRemovedRoomTopic).isEqualTo("$otherName removed the room topic") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - others must return null`() { + val otherStates = arrayOf( + OtherState.PolicyRuleRoom, OtherState.PolicyRuleServer, OtherState.PolicyRuleUser, OtherState.RoomAliases, OtherState.RoomCanonicalAlias, + OtherState.RoomGuestAccess, OtherState.RoomHistoryVisibility, OtherState.RoomJoinRules, OtherState.RoomPinnedEvents, OtherState.RoomPowerLevels, + OtherState.RoomServerAcl, OtherState.RoomTombstone, OtherState.SpaceChild, OtherState.SpaceParent, OtherState.Custom("custom_event_type") + ) + + val results = otherStates.map { state -> + val content = StateContent("", state) + val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(event, false) + state to result + } + val expected = otherStates.map { it to null } + Truth.assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Profile change + + @Test + @Config(qualifiers = "en") + fun `Profile change - avatar`() { + val otherName = "Someone" + val changedContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = "old_avatar_url") + val setContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = null) + val removedContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = "old_avatar_url") + val invalidContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = null) + val sameContent = aProfileChangeMessageContent(avatarUrl = "same_avatar_url", prevAvatarUrl = "same_avatar_url") + + val youChangedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedAvatar = formatter.format(youChangedAvatarEvent, false) + Truth.assertThat(youChangedAvatar).isEqualTo("You changed your avatar") + + val someoneChangeAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangeAvatar = formatter.format(someoneChangeAvatarEvent, false) + Truth.assertThat(someoneChangeAvatar).isEqualTo("$otherName changed their avatar") + + val youSetAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetAvatar = formatter.format(youSetAvatarEvent, false) + Truth.assertThat(youSetAvatar).isEqualTo("You changed your avatar") + + val someoneSetAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetAvatar = formatter.format(someoneSetAvatarEvent, false) + Truth.assertThat(someoneSetAvatar).isEqualTo("$otherName changed their avatar") + + val youRemovedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedAvatar = formatter.format(youRemovedAvatarEvent, false) + Truth.assertThat(youRemovedAvatar).isEqualTo("You changed your avatar") + + val someoneRemovedAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedAvatar = formatter.format(someoneRemovedAvatarEvent, false) + Truth.assertThat(someoneRemovedAvatar).isEqualTo("$otherName changed their avatar") + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent, false) + Truth.assertThat(unchangedResult).isNull() + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent, false) + Truth.assertThat(invalidResult).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val otherName = "Someone" + val changedContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = oldDisplayName) + val setContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = null) + val removedContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = oldDisplayName) + val sameContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = newDisplayName) + val invalidContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = null) + + val youChangedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedDisplayName = formatter.format(youChangedDisplayNameEvent, false) + Truth.assertThat(youChangedDisplayName).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName") + + val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent, false) + Truth.assertThat(someoneChangedDisplayName).isEqualTo("$otherName changed their display name from $oldDisplayName to $newDisplayName") + + val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetDisplayName = formatter.format(youSetDisplayNameEvent, false) + Truth.assertThat(youSetDisplayName).isEqualTo("You set your display name to $newDisplayName") + + val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent, false) + Truth.assertThat(someoneSetDisplayName).isEqualTo("$otherName set their display name to $newDisplayName") + + val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent, false) + Truth.assertThat(youRemovedDisplayName).isEqualTo("You removed your display name (it was $oldDisplayName)") + + val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent, false) + Truth.assertThat(someoneRemovedDisplayName).isEqualTo("$otherName removed their display name (it was $oldDisplayName)") + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent, false) + Truth.assertThat(unchangedResult).isNull() + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent, false) + Truth.assertThat(invalidResult).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name & avatar`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val changedContent = aProfileChangeMessageContent( + displayName = newDisplayName, + prevDisplayName = oldDisplayName, + avatarUrl = "new_avatar_url", + prevAvatarUrl = "old_avatar_url", + ) + val invalidContent = aProfileChangeMessageContent( + displayName = null, + prevDisplayName = null, + avatarUrl = null, + prevAvatarUrl = null, + ) + val sameContent = aProfileChangeMessageContent( + displayName = newDisplayName, + prevDisplayName = newDisplayName, + avatarUrl = "same_avatar_url", + prevAvatarUrl = "same_avatar_url", + ) + + val youChangedBothEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedBoth = formatter.format(youChangedBothEvent, false) + Truth.assertThat(youChangedBoth).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName\n(avatar was changed too)") + + val invalidContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = invalidContent) + val invalidMessage = formatter.format(invalidContentEvent, false) + Truth.assertThat(invalidMessage).isNull() + + val sameContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = sameContent) + val sameMessage = formatter.format(sameContentEvent, false) + Truth.assertThat(sameMessage).isNull() + } + + // endregion + + private fun createRoomEvent(sentByYou: Boolean, senderDisplayName: String?, content: EventContent): EventTimelineItem { + val sender = if (sentByYou) A_USER_ID else UserId("@someone_else:domain") + val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null) + return anEventTimelineItem(content = content, senderProfile = profile, sender = sender) + } +} diff --git a/libraries/eventformatter/test/build.gradle.kts b/libraries/eventformatter/test/build.gradle.kts new file mode 100644 index 0000000000..8250c57247 --- /dev/null +++ b/libraries/eventformatter/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.eventformatter.test" +} + +dependencies { + implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt new file mode 100644 index 0000000000..cd723a27af --- /dev/null +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLastMessageFormatter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.eventformatter.test + +import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +class FakeRoomLastMessageFormatter : RoomLastMessageFormatter { + + private var result: CharSequence? = null + + override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? { + return result + } + + fun givenFormatResult(result: CharSequence?) { + this.result = result + } +} diff --git a/libraries/featureflag/api/build.gradle.kts b/libraries/featureflag/api/build.gradle.kts new file mode 100644 index 0000000000..9420821932 --- /dev/null +++ b/libraries/featureflag/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.featureflag.api" +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt new file mode 100644 index 0000000000..8343bca8e4 --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.api + +interface Feature { + /** + * Unique key to identify the feature. + */ + val key: String + + /** + * Title to show in the UI. Not needed to be translated as it's only dev accessible. + */ + val title: String + + /** + * Optional description to give more context on the feature. + */ + val description: String? + + /** + * The default value of the feature (enabled or disabled). + */ + val defaultValue: Boolean +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt new file mode 100644 index 0000000000..59e224a1ae --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.api + +interface FeatureFlagService { + /** + * @param feature the feature to check for + * + * @return true if the feature is enabled + */ + suspend fun isFeatureEnabled(feature: Feature): Boolean + + /** + * @param feature the feature to enable or disable + * @param enabled true to enable the feature + * + * @return true if the method succeeds, ie if a RuntimeFeatureFlagProvider is registered + */ + suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean +} diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt new file mode 100644 index 0000000000..920be09389 --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.api + +enum class FeatureFlags( + override val key: String, + override val title: String, + override val description: String? = null, + override val defaultValue: Boolean = true +) : Feature { + CollapseRoomStateEvents( + key = "feature.collapseroomstateevents", + title = "Collapse room state events", + ), + ShowStartChatFlow( + key = "feature.showstartchatflow", + title = "Show start chat flow", + ), + ShowMediaUploadingFlow( + key = "feature.showmediauploadingflow", + title = "Show media uploading flow", + ) +} diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts new file mode 100644 index 0000000000..ba0747b28e --- /dev/null +++ b/libraries/featureflag/impl/build.gradle.kts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.featureflag.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + api(projects.libraries.featureflag.api) + implementation(libs.dagger) + implementation(libs.androidx.datastore.preferences) + implementation(projects.libraries.di) + implementation(projects.libraries.core) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt new file mode 100644 index 0000000000..ae498e67df --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlags +import javax.inject.Inject + +class BuildtimeFeatureFlagProvider @Inject constructor() : + FeatureFlagProvider { + + override val priority: Int + get() = LOW_PRIORITY + + override suspend fun isFeatureEnabled(feature: Feature): Boolean { + return if (feature is FeatureFlags) { + when (feature) { + FeatureFlags.CollapseRoomStateEvents -> false + FeatureFlags.ShowStartChatFlow -> false + FeatureFlags.ShowMediaUploadingFlow -> false + } + } else { + false + } + } + + override fun hasFeature(feature: Feature) = true +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt new file mode 100644 index 0000000000..7298929aea --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlagService +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultFeatureFlagService @Inject constructor( + private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider> +) : FeatureFlagService { + + override suspend fun isFeatureEnabled(feature: Feature): Boolean { + return providers.filter { it.hasFeature(feature) } + .sortedByDescending(FeatureFlagProvider::priority) + .firstOrNull() + ?.isFeatureEnabled(feature) + ?: feature.defaultValue + } + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { + return providers.filterIsInstance(RuntimeFeatureFlagProvider::class.java) + .sortedBy(FeatureFlagProvider::priority) + .firstOrNull() + ?.setFeatureEnabled(feature, enabled) + ?.let { true } + ?: false + } +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeatureFlagProvider.kt new file mode 100644 index 0000000000..833d9f9003 --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeatureFlagProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature + +interface FeatureFlagProvider { + val priority: Int + suspend fun isFeatureEnabled(feature: Feature): Boolean + fun hasFeature(feature: Feature): Boolean +} + +const val LOW_PRIORITY = 0 +const val MEDIUM_PRIORITY = 1 +const val HIGH_PRIORITY = 2 + diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt new file mode 100644 index 0000000000..15ab08b338 --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.featureflag.api.Feature +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_featureflag") + +class PreferencesFeatureFlagProvider @Inject constructor(@ApplicationContext context: Context) : RuntimeFeatureFlagProvider { + + private val store = context.dataStore + + override val priority: Int + get() = MEDIUM_PRIORITY + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) { + store.edit { prefs -> + prefs[booleanPreferencesKey(feature.key)] = enabled + } + } + + override suspend fun isFeatureEnabled(feature: Feature): Boolean { + return store.data.map { prefs -> + prefs[booleanPreferencesKey(feature.key)] ?: feature.defaultValue + }.first() + } + + override fun hasFeature(feature: Feature): Boolean { + return true + } +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt new file mode 100644 index 0000000000..1238ad354c --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/RuntimeFeatureFlagProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature + +interface RuntimeFeatureFlagProvider : FeatureFlagProvider { + suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt new file mode 100644 index 0000000000..07ee53ceee --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.multibindings.ElementsIntoSet +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.featureflag.impl.BuildtimeFeatureFlagProvider +import io.element.android.libraries.featureflag.impl.FeatureFlagProvider +import io.element.android.libraries.featureflag.impl.PreferencesFeatureFlagProvider + +@Module +@ContributesTo(AppScope::class) +object FeatureFlagModule { + + @JvmStatic + @Provides + @ElementsIntoSet + fun providesFeatureFlagProvider( + buildType: BuildType, + runtimeFeatureFlagProvider: PreferencesFeatureFlagProvider, + buildtimeFeatureFlagProvider: BuildtimeFeatureFlagProvider, + ): Set<FeatureFlagProvider> { + val providers = HashSet<FeatureFlagProvider>() + if (buildType == BuildType.RELEASE) { + providers.add(buildtimeFeatureFlagProvider) + } else { + providers.add(runtimeFeatureFlagProvider) + } + return providers + } +} diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt new file mode 100644 index 0000000000..5f7f01423a --- /dev/null +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFeatureFlagServiceTest { + + @Test + fun `given service without provider when feature is checked then it returns the default value`() = runTest { + val featureFlagService = DefaultFeatureFlagService(emptySet()) + val isFeatureEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow) + assertThat(isFeatureEnabled).isEqualTo(FeatureFlags.ShowStartChatFlow.defaultValue) + } + + @Test + fun `given service without provider when set enabled feature is called then it returns false`() = runTest { + val featureFlagService = DefaultFeatureFlagService(emptySet()) + val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) + assertThat(result).isEqualTo(false) + } + + @Test + fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest { + val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0) + val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider)) + val result = featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) + assertThat(result).isEqualTo(true) + } + + @Test + fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest { + val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0) + val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider)) + featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) + assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true) + featureFlagService.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false) + assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(false) + } + + @Test + fun `given service with 2 runtime providers when feature is checked then it uses the priority correctly`() = runTest { + val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY) + val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY) + val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider)) + lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, false) + highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.ShowStartChatFlow, true) + assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowStartChatFlow)).isEqualTo(true) + } +} diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt new file mode 100644 index 0000000000..5ff5cf932f --- /dev/null +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeRuntimeFeatureFlagProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature + +class FakeRuntimeFeatureFlagProvider(override val priority: Int) : RuntimeFeatureFlagProvider { + + private val enabledFeatures = HashMap<String, Boolean>() + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) { + enabledFeatures[feature.key] = enabled + } + + override suspend fun isFeatureEnabled(feature: Feature): Boolean { + return enabledFeatures[feature.key] ?: feature.defaultValue + } + + override fun hasFeature(feature: Feature): Boolean = true +} diff --git a/libraries/featureflag/test/build.gradle.kts b/libraries/featureflag/test/build.gradle.kts new file mode 100644 index 0000000000..952b9323f6 --- /dev/null +++ b/libraries/featureflag/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.featureflag.test" + + dependencies { + api(projects.libraries.featureflag.api) + } +} diff --git a/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeatureFlagService.kt b/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeatureFlagService.kt new file mode 100644 index 0000000000..548ffa7cc4 --- /dev/null +++ b/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeatureFlagService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.test + +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlagService + +class FakeFeatureFlagService( + initialState: Map<String, Boolean> = emptyMap() +) : FeatureFlagService { + + private val enabledFeatures = HashMap(initialState) + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { + enabledFeatures[feature.key] = enabled + return true + } + + override suspend fun isFeatureEnabled(feature: Feature): Boolean { + return enabledFeatures[feature.key] ?: feature.defaultValue + } +} diff --git a/libraries/featureflag/ui/build.gradle.kts b/libraries/featureflag/ui/build.gradle.kts new file mode 100644 index 0000000000..1d5aac6c57 --- /dev/null +++ b/libraries/featureflag/ui/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.featureflag.ui" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.designsystem) + ksp(libs.showkase.processor) +} diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt new file mode 100644 index 0000000000..87d0125278 --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.preferences.PreferenceCheckbox +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun FeatureListView( + features: ImmutableList<FeatureUiModel>, + onCheckedChange: (FeatureUiModel, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + ) { + features.forEach { feature -> + fun onCheckedChange(isChecked: Boolean) { + onCheckedChange(feature, isChecked) + } + + FeaturePreferenceView(feature = feature, onCheckedChange = ::onCheckedChange) + } + } +} + +@Composable +fun FeaturePreferenceView( + feature: FeatureUiModel, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + PreferenceCheckbox( + title = feature.title, + isChecked = feature.isEnabled, + modifier = modifier, + onCheckedChange = onCheckedChange + ) +} + +@Preview +@Composable +internal fun FeatureListViewLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun FeatureListViewDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + FeatureListView( + features = aFeatureUiModelList(), + onCheckedChange = { _, _ -> } + ) +} diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt new file mode 100644 index 0000000000..5a3eecebe0 --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.ui.model + +data class FeatureUiModel( + val key: String, + val title: String, + val isEnabled: Boolean +) diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt new file mode 100644 index 0000000000..233331d46d --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.featureflag.ui.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +fun aFeatureUiModelList(): ImmutableList<FeatureUiModel> { + return persistentListOf( + FeatureUiModel("key1", "Display State Events", true), + FeatureUiModel("key2", "Display Room Events", false) + ) +} diff --git a/libraries/maplibre-compose/build.gradle.kts b/libraries/maplibre-compose/build.gradle.kts new file mode 100644 index 0000000000..e2a9b821ba --- /dev/null +++ b/libraries/maplibre-compose/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.maplibre.compose" + + kotlinOptions { + freeCompilerArgs += "-Xexplicit-api=strict" + } +} + +dependencies { + api(libs.maplibre) + api(libs.maplibre.ktx) + api(libs.maplibre.annotation) +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt new file mode 100644 index 0000000000..0c85d3dfb3 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.location.modes.CameraMode as InternalCameraMode + +@Immutable +public enum class CameraMode { + NONE, + NONE_COMPASS, + NONE_GPS, + TRACKING, + TRACKING_COMPASS, + TRACKING_GPS, + TRACKING_GPS_NORTH; + + @InternalCameraMode.Mode + internal fun toInternal(): Int = when (this) { + NONE -> InternalCameraMode.NONE + NONE_COMPASS -> InternalCameraMode.NONE_COMPASS + NONE_GPS -> InternalCameraMode.NONE_GPS + TRACKING -> InternalCameraMode.TRACKING + TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS + TRACKING_GPS -> InternalCameraMode.TRACKING_GPS + TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH + } + + internal companion object { + fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) { + InternalCameraMode.NONE -> NONE + InternalCameraMode.NONE_COMPASS -> NONE_COMPASS + InternalCameraMode.NONE_GPS -> NONE_GPS + InternalCameraMode.TRACKING -> TRACKING + InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS + InternalCameraMode.TRACKING_GPS -> TRACKING_GPS + InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH + else -> error("Unknown camera mode: $mode") + } + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt new file mode 100644 index 0000000000..10c9d8b69a --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_ANIMATION +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_GESTURE +import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION + +/** + * Enumerates the different reasons why the map camera started to move. + * + * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + * + * [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed. + * + * [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this + * may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which + * case this library should be updated to include a new enum value for that constant. + */ +@Immutable +public enum class CameraMoveStartedReason(public val value: Int) { + UNKNOWN(-2), + NO_MOVEMENT_YET(-1), + GESTURE(REASON_API_GESTURE), + API_ANIMATION(REASON_API_ANIMATION), + DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION); + + public companion object { + /** + * Converts from the Maps SDK [com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener] + * constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such + * [CameraMoveStartedReason] for the given [value]. + * + * See https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + */ + public fun fromInt(value: Int): CameraMoveStartedReason { + return values().firstOrNull { it.value == value } ?: return UNKNOWN + } + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt new file mode 100644 index 0000000000..114e6acc02 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import android.location.Location +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Projection +import kotlinx.parcelize.Parcelize + +/** + * Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver]. + * [init] will be called when the [CameraPositionState] is first created to configure its + * initial state. + */ +@Composable +public inline fun rememberCameraPositionState( + key: String? = null, + crossinline init: CameraPositionState.() -> Unit = {} +): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) { + CameraPositionState().apply(init) +} + +/** + * A state object that can be hoisted to control and observe the map's camera state. + * A [CameraPositionState] may only be used by a single [MapboxMap] composable at a time + * as it reflects instance state for a single view of a map. + * + * @param position the initial camera position + * @param cameraMode the initial camera mode + */ +public class CameraPositionState( + position: CameraPosition = CameraPosition.Builder().build(), + cameraMode: CameraMode = CameraMode.NONE, +) { + /** + * Whether the camera is currently moving or not. This includes any kind of movement: + * panning, zooming, or rotation. + */ + public var isMoving: Boolean by mutableStateOf(false) + internal set + + /** + * The reason for the start of the most recent camera moment, or + * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or + * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. + */ + public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( + CameraMoveStartedReason.NO_MOVEMENT_YET + ) + internal set + + /** + * Returns the current [Projection] to be used for converting between screen + * coordinates and lat/lng. + */ + public val projection: Projection? + get() = map?.projection + + /** + * Local source of truth for the current camera position. + * While [map] is non-null this reflects the current position of [map] as it changes. + * While [map] is null it reflects the last known map position, or the last value set by + * explicitly setting [position]. + */ + internal var rawPosition by mutableStateOf(position) + + /** + * Current position of the camera on the map. + */ + public var position: CameraPosition + get() = rawPosition + set(value) { + synchronized(lock) { + val map = map + if (map == null) { + rawPosition = value + } else { + map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) + } + } + } + + /** + * Local source of truth for the current camera mode. + * While [map] is non-null this reflects the current camera mode as it changes. + * While [map] is null it reflects the last known camera mode, or the last value set by + * explicitly setting [cameraMode]. + */ + internal var rawCameraMode by mutableStateOf(cameraMode) + + /** + * Current tracking mode of the camera. + */ + public var cameraMode: CameraMode + get() = rawCameraMode + set(value) { + synchronized(lock) { + val map = map + if (map == null) { + rawCameraMode = value + } else { + map.locationComponent.cameraMode = value.toInternal() + } + } + } + + /** + * The user's last available location. + */ + public var location: Location? by mutableStateOf(null) + internal set + + // Used to perform side effects thread-safely. + // Guards all mutable properties that are not `by mutableStateOf`. + private val lock = Unit + + // The map currently associated with this CameraPositionState. + // Guarded by `lock`. + private var map: MapboxMap? by mutableStateOf(null) + + // The current map is set and cleared by side effect. + // There can be only one associated at a time. + internal fun setMap(map: MapboxMap?) { + synchronized(lock) { + if (this.map == null && map == null) return + if (this.map != null && map != null) { + error("CameraPositionState may only be associated with one MapboxMap at a time") + } + this.map = map + if (map == null) { + isMoving = false + } else { + map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + map.locationComponent.cameraMode = cameraMode.toInternal() + } + } + } + + public companion object { + /** + * The default saver implementation for [CameraPositionState]. + */ + public val Saver: Saver<CameraPositionState, SaveableCameraPositionState> = Saver( + save = { SaveableCameraPositionState(it.position, it.cameraMode.toInternal()) }, + restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) } + ) + } +} + +/** Provides the [CameraPositionState] used by the map. */ +internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() } + +/** The current [CameraPositionState] used by the map. */ +public val currentCameraPositionState: CameraPositionState + @[MapboxMapComposable ReadOnlyComposable Composable] + get() = LocalCameraPositionState.current + +@Parcelize +public data class SaveableCameraPositionState( + val position: CameraPosition, + val cameraMode: Int +) : Parcelable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt new file mode 100644 index 0000000000..25f6f38c66 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import com.mapbox.mapboxsdk.style.layers.Property + +@Immutable +public enum class IconAnchor { + CENTER, + LEFT, + RIGHT, + TOP, + BOTTOM, + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT; + + @Property.ICON_ANCHOR + internal fun toInternal(): String = when (this) { + CENTER -> Property.ICON_ANCHOR_CENTER + LEFT -> Property.ICON_ANCHOR_LEFT + RIGHT -> Property.ICON_ANCHOR_RIGHT + TOP -> Property.ICON_ANCHOR_TOP + BOTTOM -> Property.ICON_ANCHOR_BOTTOM + TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT + TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT + BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT + BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt new file mode 100644 index 0000000000..b6cfff034a --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.AbstractApplier +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager + +internal interface MapNode { + fun onAttached() {} + fun onRemoved() {} + fun onCleared() {} +} + +private object MapNodeRoot : MapNode + +internal class MapApplier( + val map: MapboxMap, + val style: Style, + val symbolManager: SymbolManager, +) : AbstractApplier<MapNode>(MapNodeRoot) { + + private val decorations = mutableListOf<MapNode>() + + override fun onClear() { + symbolManager.deleteAll() + decorations.forEach { it.onCleared() } + decorations.clear() + } + + override fun insertBottomUp(index: Int, instance: MapNode) { + decorations.add(index, instance) + instance.onAttached() + } + + override fun insertTopDown(index: Int, instance: MapNode) { + // insertBottomUp is preferred + } + + override fun move(from: Int, to: Int, count: Int) { + decorations.move(from, to, count) + } + + override fun remove(index: Int, count: Int) { + repeat(count) { + decorations[index + it].onRemoved() + } + decorations.remove(index, count) + } +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt new file mode 100644 index 0000000000..4b7b7005f2 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +internal val DefaultMapLocationSettings = MapLocationSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapLocationSettings( + public val locationEnabled: Boolean = false, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt new file mode 100644 index 0000000000..4bd2ff9e1e --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapSymbolManagerSettings( + public val iconAllowOverlap: Boolean = false, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt new file mode 100644 index 0000000000..a18c05a8f9 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import android.view.Gravity +import androidx.compose.ui.graphics.Color + +internal val DefaultMapUiSettings = MapUiSettings() + +/** + * Data class for UI-related settings on the map. + * + * Note: Should not be a data class if in need of maintaining binary compatibility + * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ + */ +public data class MapUiSettings( + public val compassEnabled: Boolean = true, + public val rotationGesturesEnabled: Boolean = true, + public val scrollGesturesEnabled: Boolean = true, + public val tiltGesturesEnabled: Boolean = true, + public val zoomGesturesEnabled: Boolean = true, + public val logoGravity: Int = Gravity.BOTTOM, + public val attributionGravity: Int = Gravity.BOTTOM, + public val attributionTintColor: Color = Color.Unspecified, +) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt new file mode 100644 index 0000000000..d7d5f9ca11 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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. + */ +@file:Suppress("MatchingDeclarationName") +package io.element.android.libraries.maplibre.compose + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions +import com.mapbox.mapboxsdk.location.LocationComponentOptions +import com.mapbox.mapboxsdk.location.OnCameraTrackingChangedListener +import com.mapbox.mapboxsdk.location.engine.LocationEngineRequest +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style + +private const val LOCATION_REQUEST_INTERVAL = 750L + +internal class MapPropertiesNode( + val map: MapboxMap, + style: Style, + context: Context, + cameraPositionState: CameraPositionState, +) : MapNode { + + init { + map.locationComponent.activateLocationComponent( + LocationComponentActivationOptions.Builder(context, style) + .locationComponentOptions( + LocationComponentOptions.builder(context) + .pulseEnabled(true) + .build() + ) + .locationEngineRequest( + LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL) + .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) + .setFastestInterval(LOCATION_REQUEST_INTERVAL) + .build() + ) + .build() + ) + cameraPositionState.setMap(map) + } + + var cameraPositionState = cameraPositionState + set(value) { + if (value == field) return + field.setMap(null) + field = value + value.setMap(map) + } + + override fun onAttached() { + map.addOnCameraIdleListener { + cameraPositionState.isMoving = false + // addOnCameraIdleListener is only invoked when the camera position + // is changed via .animate(). To handle updating state when .move() + // is used, it's necessary to set the camera's position here as well + cameraPositionState.rawPosition = map.cameraPosition + // Updating user location on every camera move due to lack of a better location updates API. + cameraPositionState.location = map.locationComponent.lastKnownLocation + } + map.addOnCameraMoveCancelListener { + cameraPositionState.isMoving = false + } + map.addOnCameraMoveStartedListener { + cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it) + cameraPositionState.isMoving = true + } + map.addOnCameraMoveListener { + cameraPositionState.rawPosition = map.cameraPosition + // Updating user location on every camera move due to lack of a better location updates API. + cameraPositionState.location = map.locationComponent.lastKnownLocation + } + map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener { + override fun onCameraTrackingDismissed() {} + + override fun onCameraTrackingChanged(currentMode: Int) { + cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode) + } + }) + } + + override fun onRemoved() { + cameraPositionState.setMap(null) + } + + override fun onCleared() { + cameraPositionState.setMap(null) + } +} + +/** + * Used to keep the primary map properties up to date. This should never leave the map composition. + */ +@SuppressLint("MissingPermission") +@Suppress("NOTHING_TO_INLINE") +@Composable +internal inline fun MapUpdater( + cameraPositionState: CameraPositionState, + mapLocationSettings: MapLocationSettings, + mapUiSettings: MapUiSettings, + mapSymbolManagerSettings: MapSymbolManagerSettings, +) { + val mapApplier = currentComposer.applier as MapApplier + val map = mapApplier.map + val style = mapApplier.style + val symbolManager = mapApplier.symbolManager + val context = LocalContext.current + ComposeNode<MapPropertiesNode, MapApplier>( + factory = { + MapPropertiesNode( + map = map, + style = style, + context = context, + cameraPositionState = cameraPositionState, + ) + }, + update = { + set(mapLocationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it } + + set(mapUiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it } + set(mapUiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it } + set(mapUiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it } + set(mapUiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it } + set(mapUiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it } + set(mapUiSettings.logoGravity) { map.uiSettings.logoGravity = it } + set(mapUiSettings.attributionGravity) { map.uiSettings.attributionGravity = it } + set(mapUiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) } + + set(mapSymbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it } + + update(cameraPositionState) { this.cameraPositionState = it } + } + ) +} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt new file mode 100644 index 0000000000..3c3cf3e44f --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import android.content.ComponentCallbacks +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.awaitCancellation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * A compose container for a MapLibre [MapView]. + * + * Heavily inspired by https://github.com/googlemaps/android-maps-compose + * + * @param styleUri a URI where to asynchronously fetch a style for the map + * @param modifier Modifier to be applied to the MapboxMap + * @param images images added to the map's style to be later used with [Symbol] + * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's + * camera state + * @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map + * @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings + * @param locationSettings the [MapLocationSettings] to be used for location settings + * @param content the content of the map + */ +@Composable +public fun MapboxMap( + styleUri: String, + modifier: Modifier = Modifier, + images: ImmutableMap<String, Int> = persistentMapOf(), + cameraPositionState: CameraPositionState = rememberCameraPositionState(), + uiSettings: MapUiSettings = DefaultMapUiSettings, + symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, + locationSettings: MapLocationSettings = DefaultMapLocationSettings, + content: (@Composable @MapboxMapComposable () -> Unit)? = null, +) { + // When in preview, early return a Box with the received modifier preserving layout + if (LocalInspectionMode.current) { + @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. + Box( + modifier = modifier.background(Color.DarkGray) + ) { + Text("[Map]", modifier = Modifier.align(Alignment.Center)) + } + return + } + + val context = LocalContext.current + val mapView = remember { + Mapbox.getInstance(context) + MapView(context) + } + + @Suppress("ModifierReused") + AndroidView(modifier = modifier, factory = { mapView }) + MapLifecycle(mapView) + + // rememberUpdatedState and friends are used here to make these values observable to + // the subcomposition without providing a new content function each recomposition + val currentCameraPositionState by rememberUpdatedState(cameraPositionState) + val currentUiSettings by rememberUpdatedState(uiSettings) + val currentMapLocationSettings by rememberUpdatedState(locationSettings) + val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings) + + val parentComposition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + + LaunchedEffect(styleUri, images) { + disposingComposition { + parentComposition.newComposition( + context = context, + mapView = mapView, + styleUri = styleUri, + images = images, + ) { + MapUpdater( + cameraPositionState = currentCameraPositionState, + mapUiSettings = currentUiSettings, + mapLocationSettings = currentMapLocationSettings, + mapSymbolManagerSettings = currentSymbolManagerSettings, + ) + CompositionLocalProvider( + LocalCameraPositionState provides cameraPositionState, + ) { + currentContent?.invoke() + } + } + } + } +} + +private suspend inline fun disposingComposition(factory: () -> Composition) { + val composition = factory() + try { + awaitCancellation() + } finally { + composition.dispose() + } +} + +private suspend inline fun CompositionContext.newComposition( + context: Context, + mapView: MapView, + styleUri: String, + images: ImmutableMap<String, Int>, + noinline content: @Composable () -> Unit +): Composition { + val map = mapView.awaitMap() + val style = map.awaitStyle(context, styleUri, images) + val symbolManager = SymbolManager(mapView, map, style) + return Composition( + MapApplier(map, style, symbolManager), this + ).apply { + setContent(content) + } +} + +private suspend inline fun MapView.awaitMap(): MapboxMap = suspendCoroutine { continuation -> + getMapAsync { map -> + continuation.resume(map) + } +} + +private suspend inline fun MapboxMap.awaitStyle( + context: Context, + styleUri: String, + images: ImmutableMap<String, Int>, +): Style = suspendCoroutine { continuation -> + setStyle( + Style.Builder().apply { + fromUri(styleUri) + images.forEach { (id, drawableRes) -> + withImage(id, checkNotNull(context.getDrawable(drawableRes)) { + "Drawable resource $drawableRes with id $id not found" + }) + } + } + ) { style -> + continuation.resume(style) + } +} + +/** + * Registers lifecycle observers to the local [MapView]. + */ +@Composable +private fun MapLifecycle(mapView: MapView) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } + DisposableEffect(context, lifecycle, mapView) { + val mapLifecycleObserver = mapView.lifecycleObserver(previousState) + val callbacks = mapView.componentCallbacks() + + lifecycle.addObserver(mapLifecycleObserver) + context.registerComponentCallbacks(callbacks) + + onDispose { + lifecycle.removeObserver(mapLifecycleObserver) + context.unregisterComponentCallbacks(callbacks) + } + } + DisposableEffect(mapView) { + onDispose { + mapView.onDestroy() + mapView.removeAllViews() + } + } +} + +private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver = + LifecycleEventObserver { _, event -> + event.targetState + when (event) { + Lifecycle.Event.ON_CREATE -> { + // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in + // this case the MapboxMap composable also doesn't leave the composition. So, + // recreating the map does not restore state properly which must be avoided. + if (previousState.value != Lifecycle.Event.ON_STOP) { + this.onCreate(Bundle()) + } + } + Lifecycle.Event.ON_START -> this.onStart() + Lifecycle.Event.ON_RESUME -> this.onResume() + Lifecycle.Event.ON_PAUSE -> this.onPause() + Lifecycle.Event.ON_STOP -> this.onStop() + Lifecycle.Event.ON_DESTROY -> { + //handled in onDispose + } + else -> throw IllegalStateException() + } + previousState.value = event + } + +private fun MapView.componentCallbacks(): ComponentCallbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(config: Configuration) {} + + override fun onLowMemory() { + this@componentCallbacks.onLowMemory() + } + } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt new file mode 100644 index 0000000000..15876b0033 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.ComposableTargetMarker + +/** + * An annotation that can be used to mark a composable function as being expected to be use in a + * composable function that is also marked or inferred to be marked as a [MapboxMapComposable]. + * + * This will produce build warnings when [MapboxMapComposable] composable functions are used outside + * of a [MapboxMapComposable] content lambda, and vice versa. + */ +@Retention(AnnotationRetention.BINARY) +@ComposableTargetMarker(description = "MapLibre Map Composable") +@Target( + AnnotationTarget.FILE, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, +) +public annotation class MapboxMapComposable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt new file mode 100644 index 0000000000..36e8cdc34e --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * Copyright 2021 Google LLC + * Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose) + * + * 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 io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.plugins.annotation.Symbol +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions + +internal class SymbolNode( + val symbolManager: SymbolManager, + val symbol: Symbol, +) : MapNode { + override fun onRemoved() { + symbolManager.delete(symbol) + } + + override fun onCleared() { + symbolManager.delete(symbol) + } +} + +/** + * A state object that can be hoisted to control and observe the symbol state. + * + * @param position the initial symbol position + */ +public class SymbolState( + position: LatLng = LatLng(0.0, 0.0) +) { + /** + * Current position of the symbol. + */ + public var position: LatLng by mutableStateOf(position) + + public companion object { + /** + * The default saver implementation for [SymbolState]. + */ + public val Saver: Saver<SymbolState, LatLng> = Saver( + save = { it.position }, + restore = { SymbolState(it) } + ) + } +} + +@Composable +public fun rememberSymbolState( + key: String? = null, + position: LatLng = LatLng(0.0, 0.0) +): SymbolState = rememberSaveable(key = key, saver = SymbolState.Saver) { + SymbolState(position) +} + +/** + * A composable for a symbol on the map. + * + * @param iconId an id of an image from the current [Style] + * @param state the [SymbolState] to be used to control or observe the symbol + * state such as its position and info window + * @param iconAnchor the anchor for the symbol image + */ +@Composable +@MapboxMapComposable +public fun Symbol( + iconId: String, + state: SymbolState = rememberSymbolState(), + iconAnchor: IconAnchor? = null, +) { + val mapApplier = currentComposer.applier as MapApplier + val symbolManager = mapApplier.symbolManager + ComposeNode<SymbolNode, MapApplier>( + factory = { + SymbolNode( + symbolManager = symbolManager, + symbol = symbolManager.create( + SymbolOptions().apply { + withLatLng(state.position) + withIconImage(iconId) + iconAnchor?.let { withIconAnchor(it.toInternal()) } + } + ), + ) + }, + update = { + update(state.position) { + this.symbol.latLng = it + symbolManager.update(this.symbol) + } + update(iconId) { + this.symbol.iconImage = it + symbolManager.update(this.symbol) + } + update(iconAnchor) { + this.symbol.iconAnchor = it?.toInternal() + symbolManager.update(this.symbol) + } + } + ) +} diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts new file mode 100644 index 0000000000..4ff5f3450d --- /dev/null +++ b/libraries/matrix/api/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.22" +} + +android { + namespace = "io.element.android.libraries.matrix.api" + + buildFeatures { + buildConfig = true + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.libraries.di) + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation("net.java.dev.jna:jna:5.13.0@aar") + implementation(libs.serialization.json) + api(projects.libraries.sessionStorage.api) + implementation(libs.coroutines.core) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) +} diff --git a/libraries/matrix/api/src/main/AndroidManifest.xml b/libraries/matrix/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..dc2b81fddc --- /dev/null +++ b/libraries/matrix/api/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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" /> + +</manifest> diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt new file mode 100644 index 0000000000..747de5f554 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api + +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import java.io.Closeable + +interface MatrixClient : Closeable { + val sessionId: SessionId + val roomSummaryDataSource: RoomSummaryDataSource + val mediaLoader: MatrixMediaLoader + suspend fun getRoom(roomId: RoomId): MatrixRoom? + suspend fun findDM(userId: UserId): MatrixRoom? + suspend fun ignoreUser(userId: UserId): Result<Unit> + suspend fun unignoreUser(userId: UserId): Result<Unit> + suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> + suspend fun createDM(userId: UserId): Result<RoomId> + suspend fun getProfile(userId: UserId): Result<MatrixUser> + suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> + fun syncService(): SyncService + fun sessionVerificationService(): SessionVerificationService + fun pushersService(): PushersService + fun notificationService(): NotificationService + suspend fun getCacheSize(): Long + + /** + * Will close the client and delete the cache data. + */ + suspend fun clearCache() + suspend fun logout() + suspend fun loadUserDisplayName(): Result<String> + suspend fun loadUserAvatarURLString(): Result<String?> + suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> + fun roomMembershipObserver(): RoomMembershipObserver + + fun isMe(userId: UserId?) = userId == sessionId +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt new file mode 100644 index 0000000000..44d1a1d1a6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api + +import io.element.android.libraries.matrix.api.core.SessionId + +interface MatrixClientProvider { + /** + * Can be used to get or restore a MatrixClient with the given [SessionId]. + * If a [MatrixClient] is already in memory, it'll return it. Otherwise it'll try to restore one. + * Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider. + */ + suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCode.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCode.kt new file mode 100644 index 0000000000..dfc88e6110 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCode.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +enum class AuthErrorCode(val value: String) { + UNKNOWN("M_UNKNOWN"), + USER_DEACTIVATED("M_USER_DEACTIVATED"), + FORBIDDEN("M_FORBIDDEN") +} + +// This is taken from the iOS version. It seems like currently there's no better way to extract error codes +val AuthenticationException.errorCode: AuthErrorCode + get() { + val message = (this as? AuthenticationException.Generic)?.message ?: return AuthErrorCode.UNKNOWN + return enumValues<AuthErrorCode>() + .firstOrNull { message.contains(it.value) } + ?: AuthErrorCode.UNKNOWN + } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt new file mode 100644 index 0000000000..e670e02f11 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +sealed class AuthenticationException(message: String) : Exception(message) { + class ClientMissing(message: String) : AuthenticationException(message) + class InvalidServerName(message: String) : AuthenticationException(message) + class SlidingSyncNotAvailable(message: String) : AuthenticationException(message) + class SessionMissing(message: String) : AuthenticationException(message) + class Generic(message: String) : AuthenticationException(message) + class OidcError(type: String, message: String) : AuthenticationException(message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt new file mode 100644 index 0000000000..c15153876c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface MatrixAuthenticationService { + fun isLoggedIn(): Flow<Boolean> + suspend fun getLatestSessionId(): SessionId? + suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> + fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> + suspend fun setHomeserver(homeserver: String): Result<Unit> + suspend fun login(username: String, password: String): Result<SessionId> + + /* + * OIDC part. + */ + + /** + * Get the Oidc url to display to the user. + */ + suspend fun getOidcUrl(): Result<OidcDetails> + + /** + * Cancel Oidc login sequence. + */ + suspend fun cancelOidcLogin(): Result<Unit> + + /** + * Attempt to login using the [callbackUrl] provided by the Oidc page. + */ + suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt new file mode 100644 index 0000000000..f5fc38eb16 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MatrixHomeServerDetails( + val url: String, + val supportsPasswordLogin: Boolean, + val supportsOidcLogin: Boolean, +): Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt new file mode 100644 index 0000000000..ae473885da --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +object OidcConfig { + const val redirectUri = "io.element:/callback" +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt new file mode 100644 index 0000000000..34c926c8e6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class OidcDetails( + val url: String, +) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt new file mode 100644 index 0000000000..ddce776627 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.config + +object MatrixConfiguration { + const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/" + val clientPermalinkBaseUrl: String? = null +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt new file mode 100644 index 0000000000..f2e08a6ed0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import java.io.Serializable + +@JvmInline +value class EventId(val value: String) : Serializable { + init { + if (BuildConfig.DEBUG && !MatrixPatterns.isEventId(value)) { + error("`$value` is not a valid event id.\nExample event id: `\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg`.") + } + } + + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt new file mode 100644 index 0000000000..bc0f0c04bc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import timber.log.Timber + +/** + * This class contains pattern to match the different Matrix ids + * Ref: https://matrix.org/docs/spec/appendices#identifier-grammar + */ +object MatrixPatterns { + + // Note: TLD is not mandatory (localhost, IP address...) + private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?" + + // regex pattern to find matrix user ids in a string. + // See https://matrix.org/docs/spec/appendices#historical-user-ids + private const val MATRIX_USER_IDENTIFIER_REGEX = "@[A-Z0-9\\x21-\\x39\\x3B-\\x7F]+$DOMAIN_REGEX" + val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find room ids in a string. + private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9.-]+$DOMAIN_REGEX" + private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find room aliases in a string. + private const val MATRIX_ROOM_ALIAS_REGEX = "#[A-Z0-9._%#@=+-]+$DOMAIN_REGEX" + private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find message ids in a string. + private const val MATRIX_EVENT_IDENTIFIER_REGEX = "\\$[A-Z0-9]+$DOMAIN_REGEX" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find message ids in a string. + private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$[A-Z0-9/+]+" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // Ref: https://matrix.org/docs/spec/rooms/v4#event-ids + private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$[A-Z0-9\\-_]+" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find group ids in a string. + private const val MATRIX_GROUP_IDENTIFIER_REGEX = "\\+[A-Z0-9=_\\-./]+$DOMAIN_REGEX" + private val PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER = MATRIX_GROUP_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to find permalink with message id. + // Android does not support in URL so extract it. + private const val PERMALINK_BASE_REGEX = "https://matrix\\.to/#/" + private const val APP_BASE_REGEX = "https://[A-Z0-9.-]+\\.[A-Z]{2,}/[A-Z]{3,}/#/room/" + const val SEP_REGEX = "/" + + private const val LINK_TO_ROOM_ID_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX + private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID = LINK_TO_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE) + + private const val LINK_TO_ROOM_ALIAS_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX + private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS = LINK_TO_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE) + + private const val LINK_TO_APP_ROOM_ID_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX + private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID = LINK_TO_APP_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE) + + private const val LINK_TO_APP_ROOM_ALIAS_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX + private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS = LINK_TO_APP_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE) + + // ascii characters in the range \x20 (space) to \x7E (~) + val ORDER_STRING_REGEX = "[ -~]+".toRegex() + + // list of patterns to find some matrix item. + val MATRIX_PATTERNS = listOf( + PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID, + PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS, + PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID, + PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS, + PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER, + PATTERN_CONTAIN_MATRIX_ALIAS, + PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER, + PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER, + PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER + ) + + /** + * Tells if a string is a valid session Id. This is an alias for [isUserId] + * + * @param str the string to test + * @return true if the string is a valid session id + */ + fun isSessionId(str: String?) = isUserId(str) + + /** + * Tells if a string is a valid user Id. + * + * @param str the string to test + * @return true if the string is a valid user id + */ + fun isUserId(str: String?): Boolean { + return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER + } + + /** + * Tells if a string is a valid space id. This is an alias for [isRoomId] + * + * @param str the string to test + * @return true if the string is a valid space Id + */ + fun isSpaceId(str: String?) = isRoomId(str) + + /** + * Tells if a string is a valid room id. + * + * @param str the string to test + * @return true if the string is a valid room Id + */ + fun isRoomId(str: String?): Boolean { + return str != null && str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER + } + + /** + * Tells if a string is a valid room alias. + * + * @param str the string to test + * @return true if the string is a valid room alias. + */ + fun isRoomAlias(str: String?): Boolean { + return str != null && str matches PATTERN_CONTAIN_MATRIX_ALIAS + } + + /** + * Tells if a string is a valid event id. + * + * @param str the string to test + * @return true if the string is a valid event id. + */ + fun isEventId(str: String?): Boolean { + return str != null && + (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER || + str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 || + str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4) + } + + /** + * Tells if a string is a valid thread id. This is an alias for [isEventId]. + * + * @param str the string to test + * @return true if the string is a valid thread id. + */ + fun isThreadId(str: String?) = isEventId(str) + + /** + * Tells if a string is a valid group id. + * + * @param str the string to test + * @return true if the string is a valid group id. + */ + fun isGroupId(str: String?): Boolean { + return str != null && str matches PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER + } + + /** + * Extract server name from a matrix id. + * + * @param matrixId + * @return null if not found or if matrixId is null + */ + fun extractServerNameFromId(matrixId: String?): String? { + return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() } + } + + /** + * Extract user name from a matrix id. + * + * @param matrixId + * @return null if the input is not a valid matrixId + */ + fun extractUserNameFromId(matrixId: String): String? { + return if (isUserId(matrixId)) { + matrixId.removePrefix("@").substringBefore(":", missingDelimiterValue = "") + } else { + null + } + } + + /** + * Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~), + * or consist of more than 50 characters, are forbidden and the field should be ignored if received. + */ + fun isValidOrderString(order: String?): Boolean { + return order != null && order.length < 50 && order matches ORDER_STRING_REGEX + } + + /* + fun candidateAliasFromRoomName(roomName: String, domain: String): String { + return roomName.lowercase() + .replaceSpaceChars(replacement = "_") + .removeInvalidRoomNameChars() + .take(MatrixConstants.maxAliasLocalPartLength(domain)) + } + */ + + /** + * Return the domain form a userId. + * Examples: + * - "@alice:domain.org".getDomain() will return "domain.org" + * - "@bob:domain.org:3455".getDomain() will return "domain.org:3455" + */ + fun String.getServerName(): String { + if (BuildConfig.DEBUG && !isUserId(this)) { + // They are some invalid userId localpart in the wild, but the domain part should be there anyway + Timber.w("Not a valid user ID: $this") + } + return substringAfter(":") + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt new file mode 100644 index 0000000000..2b41907eec --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +interface ProgressCallback { + fun onProgress(current: Long, total: Long) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt new file mode 100644 index 0000000000..d21d3ec368 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import java.io.Serializable + +@JvmInline +value class RoomId(val value: String) : Serializable { + + init { + if (BuildConfig.DEBUG && !MatrixPatterns.isRoomId(value)) { + error("`$value` is not a valid room id.\n Example room id: `!room_id:domain`.") + } + } + + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt new file mode 100644 index 0000000000..6009aa5c03 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +typealias SessionId = UserId diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt new file mode 100644 index 0000000000..89901b7350 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import java.io.Serializable + +@JvmInline +value class SpaceId(val value: String) : Serializable { + init { + if (BuildConfig.DEBUG && !MatrixPatterns.isSpaceId(value)) { + error( + "`$value` is not a valid space id.\n" + + "Space ids are the same as room ids.\n" + + "Example space id: `!space_id:domain`." + ) + } + } + + override fun toString(): String = value +} + +/** + * Value to use when no space is selected by the user. + */ +val MAIN_SPACE = SpaceId("!mainSpace:local") diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt new file mode 100644 index 0000000000..b6ec9766f4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import java.io.Serializable + +@JvmInline +value class ThreadId(val value: String) : Serializable { + init { + if (BuildConfig.DEBUG && !MatrixPatterns.isThreadId(value)) { + error( + "`$value` is not a valid thread id.\n" + + "Thread ids are the same as event ids.\n" + + "Example thread id: `\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg`." + ) + } + } + + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt new file mode 100644 index 0000000000..0de5ccd651 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import java.io.Serializable + +@JvmInline +value class TransactionId(val value: String) : Serializable { + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt new file mode 100644 index 0000000000..e153834501 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.BuildConfig +import java.io.Serializable + +@JvmInline +value class UserId(val value: String) : Serializable { + + init { + if (BuildConfig.DEBUG && !MatrixPatterns.isUserId(value)) { + error("`$value` is not a valid user id.\nExample user id: `@name:domain`.") + } + } + + override fun toString(): String = value +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt new file mode 100644 index 0000000000..c65aae6156 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.createroom + +import io.element.android.libraries.matrix.api.core.UserId + +data class CreateRoomParameters( + val name: String?, + val topic: String? = null, + val isEncrypted: Boolean, + val isDirect: Boolean = false, + val visibility: RoomVisibility, + val preset: RoomPreset, + val invite: List<UserId>? = null, + val avatar: String? = null, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt new file mode 100644 index 0000000000..c2254e395f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.createroom + +enum class RoomPreset { + PRIVATE_CHAT, + PUBLIC_CHAT, + TRUSTED_PRIVATE_CHAT, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt new file mode 100644 index 0000000000..d2715363e8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.createroom + +enum class RoomVisibility { + PUBLIC, + PRIVATE, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt new file mode 100644 index 0000000000..52dbd2eb12 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.exception + +sealed class ClientException(message: String) : Exception(message) { + class Generic(message: String) : ClientException(message) + class Other(message: String) : ClientException(message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt new file mode 100644 index 0000000000..bd4539bced --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +import java.time.Duration + +data class AudioInfo( + val duration: Duration?, + val size: Long?, + val mimetype: String?, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt new file mode 100644 index 0000000000..0b99e5f6bc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +data class FileInfo( + val mimetype: String?, + val size: Long?, + val thumbnailInfo: ThumbnailInfo?, + val thumbnailSource: MediaSource? +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt new file mode 100644 index 0000000000..b77fc2f4c2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +data class ImageInfo( + val height: Long?, + val width: Long?, + val mimetype: String?, + val size: Long?, + val thumbnailInfo: ThumbnailInfo?, + val thumbnailSource: MediaSource?, + val blurhash: String? +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt new file mode 100644 index 0000000000..8dd5c625d1 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +interface MatrixMediaLoader { + /** + * @param source to fetch the content for. + * @return a [Result] of ByteArray. It contains the binary data for the media. + */ + suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> + + /** + * @param source to fetch the data for. + * @param width: the desired width for rescaling the media as thumbnail + * @param height: the desired height for rescaling the media as thumbnail + * @return a [Result] of ByteArray. It contains the binary data for the media. + */ + suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> + + /** + * @param source to fetch the data for. + * @param mimeType: optional mime type. + * @param body: optional body which will be used to name the file. + * @return a [Result] of [MediaFile] + */ + suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt new file mode 100644 index 0000000000..d4989dbffc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +import java.io.Closeable +import java.io.File + +/** + * A wrapper around a media file on the disk. + * When closed the file will be removed from the disk. + */ +interface MediaFile : Closeable { + fun path(): String +} + +fun MediaFile.toFile(): File { + return File(path()) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt new file mode 100644 index 0000000000..170137302b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MediaSource( + /** + * Url of the media. + */ + val url: String, + /** + * This is used to hold data for encrypted media. + */ + val json: String? = null, +) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ThumbnailInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ThumbnailInfo.kt new file mode 100644 index 0000000000..2657d4724c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ThumbnailInfo.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +data class ThumbnailInfo( + val height: Long?, + val width: Long?, + val mimetype: String?, + val size: Long? +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt new file mode 100644 index 0000000000..b7af54c6b2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.media + +import java.time.Duration + +data class VideoInfo( + val duration: Duration?, + val height: Long?, + val width: Long?, + val mimetype: String?, + val size: Long?, + val thumbnailInfo: ThumbnailInfo?, + val thumbnailSource: MediaSource?, + val blurhash: String? +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt new file mode 100644 index 0000000000..4ded947d56 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.notification + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType + +data class NotificationData( + val senderId: UserId, + val eventId: EventId, + val roomId: RoomId, + val senderAvatarUrl: String?, + val senderDisplayName: String?, + val roomAvatarUrl: String?, + val roomDisplayName: String?, + val isDirect: Boolean, + val isEncrypted: Boolean, + val isNoisy: Boolean, + val event: NotificationEvent, +) + +data class NotificationEvent( + val timestamp: Long, + val content: NotificationContent, + // For images for instance + val contentUrl: String? +) + +sealed interface NotificationContent { + sealed interface MessageLike : NotificationContent { + object CallAnswer : MessageLike + object CallInvite : MessageLike + object CallHangup : MessageLike + object CallCandidates : MessageLike + object KeyVerificationReady : MessageLike + object KeyVerificationStart : MessageLike + object KeyVerificationCancel : MessageLike + object KeyVerificationAccept : MessageLike + object KeyVerificationKey : MessageLike + object KeyVerificationMac : MessageLike + object KeyVerificationDone : MessageLike + data class ReactionContent( + val relatedEventId: String + ) : MessageLike + object RoomEncrypted : MessageLike + data class RoomMessage( + val messageType: MessageType + ) : MessageLike + object RoomRedaction : MessageLike + object Sticker : MessageLike + } + + sealed interface StateEvent : NotificationContent { + object PolicyRuleRoom : StateEvent + object PolicyRuleServer : StateEvent + object PolicyRuleUser : StateEvent + object RoomAliases : StateEvent + object RoomAvatar : StateEvent + object RoomCanonicalAlias : StateEvent + object RoomCreate : StateEvent + object RoomEncryption : StateEvent + object RoomGuestAccess : StateEvent + object RoomHistoryVisibility : StateEvent + object RoomJoinRules : StateEvent + data class RoomMemberContent( + val userId: String, + val membershipState: RoomMembershipState + ) : StateEvent + object RoomName : StateEvent + object RoomPinnedEvents : StateEvent + object RoomPowerLevels : StateEvent + object RoomServerAcl : StateEvent + object RoomThirdPartyInvite : StateEvent + object RoomTombstone : StateEvent + object RoomTopic : StateEvent + object SpaceChild : StateEvent + object SpaceParent : StateEvent + } + +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt new file mode 100644 index 0000000000..2046252930 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.notification + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +interface NotificationService { + fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result<NotificationData?> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt new file mode 100644 index 0000000000..e352dd5cfc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.permalink + +import android.net.Uri +import io.element.android.libraries.matrix.api.config.MatrixConfiguration + +/** + * Mapping of an input URI to a matrix.to compliant URI. + */ +object MatrixToConverter { + + /** + * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url. + * To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS]. + * Examples: + * - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + */ + fun convert(uri: Uri): Uri? { + val uriString = uri.toString() + val baseUrl = MatrixConfiguration.matrixToPermalinkBaseUrl + + return when { + // URL is already a matrix.to + uriString.startsWith(baseUrl) -> uri + // Web or client url + SUPPORTED_PATHS.any { it in uriString } -> { + val path = SUPPORTED_PATHS.first { it in uriString } + Uri.parse(baseUrl + uriString.substringAfter(path)) + } + // URL is not supported + else -> null + } + } + + private val SUPPORTED_PATHS = listOf( + "/#/room/", + "/#/user/", + "/#/group/" + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt new file mode 100644 index 0000000000..6a15bfb514 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.permalink + +import io.element.android.libraries.matrix.api.config.MatrixConfiguration +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +object PermalinkBuilder { + + private const val ROOM_PATH = "room/" + private const val USER_PATH = "user/" + private const val GROUP_PATH = "group/" + + private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.matrixToPermalinkBaseUrl).also { + var baseUrl = it + if (!baseUrl.endsWith("/")) { + baseUrl += "/" + } + if (!baseUrl.endsWith("/#/")) { + baseUrl += "/#/" + } + } + + fun permalinkForUser(userId: UserId): Result<String> { + return if (MatrixPatterns.isUserId(userId.value)) { + val url = buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(USER_PATH) + } + append(userId.value) + } + Result.success(url) + } else { + Result.failure(PermalinkBuilderError.InvalidUserId) + } + } + + fun permalinkForRoomAlias(roomAlias: String): Result<String> { + return if (MatrixPatterns.isRoomAlias(roomAlias)) { + Result.success(permalinkForRoomAliasOrId(roomAlias)) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomAlias) + } + } + + fun permalinkForRoomId(roomId: RoomId): Result<String> { + return if (MatrixPatterns.isRoomId(roomId.value)) { + Result.success(permalinkForRoomAliasOrId(roomId.value)) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomId) + } + } + + private fun permalinkForRoomAliasOrId(value: String): String { + val id = escapeId(value) + return buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(ROOM_PATH) + } + append(id) + } + } + + private fun escapeId(value: String) = value.replace("/", "%2F") + + private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.matrixToPermalinkBaseUrl) +} + +sealed class PermalinkBuilderError : Throwable() { + object InvalidRoomAlias : PermalinkBuilderError() + object InvalidRoomId : PermalinkBuilderError() + object InvalidUserId : PermalinkBuilderError() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt new file mode 100644 index 0000000000..0a70055e4f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.permalink + +import android.net.Uri + +/** + * This sealed class represents all the permalink cases. + * You don't have to instantiate yourself but should use [PermalinkParser] instead. + */ +sealed class PermalinkData { + + data class RoomLink( + val roomIdOrAlias: String, + val isRoomAlias: Boolean, + val eventId: String?, + val viaParameters: List<String> + ) : PermalinkData() + + /* + * &room_name=Team2 + * &room_avatar_url=mxc: + * &inviter_name=bob + */ + data class RoomEmailInviteLink( + val roomId: String, + val email: String, + val signUrl: String, + val roomName: String?, + val roomAvatarUrl: String?, + val inviterName: String?, + val identityServer: String, + val token: String, + val privateKey: String, + val roomType: String? + ) : PermalinkData() + + data class UserLink(val userId: String) : PermalinkData() + + data class FallbackLink(val uri: Uri, val isLegacyGroupLink: Boolean = false) : PermalinkData() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt new file mode 100644 index 0000000000..fc900e4bbb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.permalink + +import android.net.Uri +import android.net.UrlQuerySanitizer +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import timber.log.Timber +import java.net.URLDecoder + +/** + * This class turns a uri to a [PermalinkData]. + * element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks + * or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org) + * or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org) + */ +object PermalinkParser { + + /** + * Turns a uri string to a [PermalinkData]. + */ + fun parse(uriString: String): PermalinkData { + val uri = Uri.parse(uriString) + return parse(uri) + } + + /** + * Turns a uri to a [PermalinkData]. + * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md + */ + fun parse(uri: Uri): PermalinkData { + // the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the + // mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid + // so convert URI to matrix.to to simplify parsing process + val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) + + // We can't use uri.fragment as it is decoding to early and it will break the parsing + // of parameters that represents url (like signurl) + val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment + if (fragment.isEmpty()) { + return PermalinkData.FallbackLink(uri) + } + val safeFragment = fragment.substringBefore('?') + val viaQueryParameters = fragment.getViaParameters() + + // we are limiting to 2 params + val params = safeFragment + .split(MatrixPatterns.SEP_REGEX) + .filter { it.isNotEmpty() } + .take(2) + + val decodedParams = params + .map { URLDecoder.decode(it, "UTF-8") } + + val identifier = params.getOrNull(0) + val decodedIdentifier = decodedParams.getOrNull(0) + val extraParameter = decodedParams.getOrNull(1) + return when { + identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri) + MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier) + MatrixPatterns.isRoomId(decodedIdentifier) -> { + handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters) + } + MatrixPatterns.isRoomAlias(decodedIdentifier) -> { + PermalinkData.RoomLink( + roomIdOrAlias = decodedIdentifier, + isRoomAlias = true, + eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, + viaParameters = viaQueryParameters + ) + } + else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier)) + } + } + + private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List<String>): PermalinkData { + // Can't rely on built in parsing because it's messing around the signurl + val paramList = safeExtractParams(fragment) + val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second + val email = paramList.firstOrNull { it.first == "email" }?.second + return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) { + try { + val signValidUri = Uri.parse(signUrl) + val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException() + val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException() + val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException() + PermalinkData.RoomEmailInviteLink( + roomId = identifier, + email = email!!, + signUrl = signUrl!!, + roomName = paramList.firstOrNull { it.first == "room_name" }?.second, + inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second, + roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second, + roomType = paramList.firstOrNull { it.first == "room_type" }?.second, + identityServer = identityServerHost, + token = token, + privateKey = privateKey + ) + } catch (failure: Throwable) { + Timber.i("## Permalink: Failed to parse permalink $signUrl") + PermalinkData.FallbackLink(uri) + } + } else { + PermalinkData.RoomLink( + roomIdOrAlias = identifier, + isRoomAlias = false, + eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) }, + viaParameters = viaQueryParameters + ) + } + } + + private fun safeExtractParams(fragment: String) = + fragment.substringAfter("?").split('&').mapNotNull { + val splitNameValue = it.split("=") + if (splitNameValue.size == 2) { + Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8")) + } else null + } + + private fun String.getViaParameters(): List<String> { + return UrlQuerySanitizer(this) + .parameterList + .filter { + it.mParameter == "via" + }.map { + URLDecoder.decode(it.mValue, "UTF-8") + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt new file mode 100644 index 0000000000..71a642965f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.pusher + +interface PushersService { + suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit> + suspend fun unsetHttpPusher(): Result<Unit> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt new file mode 100644 index 0000000000..43a90f5be2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.pusher + +data class SetHttpPusherData( + val pushKey: String, + val appId: String, + val url: String, + val appDisplayName: String, + val deviceDisplayName: String, + val profileTag: String?, + val lang: String, + val defaultPayload: String, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt new file mode 100644 index 0000000000..6b2813feb8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId + +class ForwardEventException( + val roomIds: List<RoomId> +) : Exception() { + + override val message: String? = "Failed to deliver event to $roomIds rooms" +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt new file mode 100644 index 0000000000..be0ff447b3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import kotlinx.coroutines.flow.StateFlow +import java.io.Closeable +import java.io.File + +interface MatrixRoom : Closeable { + val sessionId: SessionId + val roomId: RoomId + val name: String? + val displayName: String + val alias: String? + val alternativeAliases: List<String> + val topic: String? + val avatarUrl: String? + val isEncrypted: Boolean + val isDirect: Boolean + val isPublic: Boolean + val activeMemberCount: Long + val joinedMemberCount: Long + + /** + * The current loaded members as a StateFlow. + * Initial value is [MatrixRoomMembersState.Unknown]. + * To update them you should call [updateMembers]. + */ + val membersStateFlow: StateFlow<MatrixRoomMembersState> + + /** + * Try to load the room members and update the membersFlow. + */ + suspend fun updateMembers(): Result<Unit> + + val syncUpdateFlow: StateFlow<Long> + + val timeline: MatrixTimeline + + fun open(): Result<Unit> + + suspend fun userDisplayName(userId: UserId): Result<String?> + + suspend fun userAvatarUrl(userId: UserId): Result<String?> + + suspend fun sendMessage(message: String): Result<Unit> + + suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> + + suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> + + suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit> + + suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<Unit> + + suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<Unit> + + suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<Unit> + + suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> + + suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> + + suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> + + suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> + + suspend fun cancelSend(transactionId: TransactionId): Result<Unit> + + suspend fun leave(): Result<Unit> + + suspend fun join(): Result<Unit> + + suspend fun inviteUserById(id: UserId): Result<Unit> + + suspend fun canUserInvite(userId: UserId): Result<Boolean> + + suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> + + suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> + + suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> + + suspend fun removeAvatar(): Result<Unit> + + suspend fun setName(name: String): Result<Unit> + + suspend fun setTopic(topic: String): Result<Unit> + + suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> + + /** + * Share a location message in the room. + * + * @param body A human readable textual representation of the location. + * @param geoUri A geo URI (RFC 5870) representing the location e.g. `geo:51.5008,0.1247;u=35`. + * Respectively: latitude, longitude, and (optional) uncertainty. + * @param description Optional description of the location to display to the user. + * @param zoomLevel Optional zoom level to display the map at. + * @param assetType Optional type of the location asset. + * Set to SENDER if sharing own location. Set to PIN if sharing any location. + */ + suspend fun sendLocation( + body: String, + geoUri: String, + description: String? = null, + zoomLevel: Int? = null, + assetType: AssetType? = null, + ): Result<Unit> +} + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt new file mode 100644 index 0000000000..4e41fd43ba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomMembersState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +sealed interface MatrixRoomMembersState { + object Unknown : MatrixRoomMembersState + data class Pending(val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState + data class Error(val failure: Throwable, val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState + data class Ready(val roomMembers: List<RoomMember>) : MatrixRoomMembersState +} + +fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? { + return when (this) { + is MatrixRoomMembersState.Ready -> roomMembers + is MatrixRoomMembersState.Pending -> prevRoomMembers + is MatrixRoomMembersState.Error -> prevRoomMembers + else -> null + } +} + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt new file mode 100644 index 0000000000..109e50e602 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +enum class MessageEventType { + CALL_ANSWER, + CALL_INVITE, + CALL_HANGUP, + CALL_CANDIDATES, + KEY_VERIFICATION_READY, + KEY_VERIFICATION_START, + KEY_VERIFICATION_CANCEL, + KEY_VERIFICATION_ACCEPT, + KEY_VERIFICATION_KEY, + KEY_VERIFICATION_MAC, + KEY_VERIFICATION_DONE, + REACTION_SENT, + ROOM_ENCRYPTED, + ROOM_MESSAGE, + ROOM_REDACTION, + STICKER +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt new file mode 100644 index 0000000000..3c9bd030b0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.UserId + +data class RoomMember( + val userId: UserId, + val displayName: String?, + val avatarUrl: String?, + val membership: RoomMembershipState, + val isNameAmbiguous: Boolean, + val powerLevel: Long, + val normalizedPowerLevel: Long, + val isIgnored: Boolean, +) + +enum class RoomMembershipState { + BAN, INVITE, JOIN, KNOCK, LEAVE +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt new file mode 100644 index 0000000000..ed6f3fae26 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class RoomMembershipObserver { + data class RoomMembershipUpdate( + val roomId: RoomId, + val isUserInRoom: Boolean, + val change: MembershipChange, + ) + + private val _updates = MutableSharedFlow<RoomMembershipUpdate>(replay = 1) + val updates = _updates.asSharedFlow() + + fun notifyUserLeftRoom(roomId: RoomId) { + _updates.tryEmit(RoomMembershipUpdate(roomId, false, MembershipChange.LEFT)) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt new file mode 100644 index 0000000000..7dedd86b63 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.message.RoomMessage + +sealed interface RoomSummary { + data class Empty(val identifier: String) : RoomSummary + data class Filled(val details: RoomSummaryDetails) : RoomSummary + + fun identifier(): String { + return when (this) { + is Empty -> identifier + is Filled -> details.roomId.value + } + } +} + +data class RoomSummaryDetails( + val roomId: RoomId, + val name: String, + val canonicalAlias: String? = null, + val isDirect: Boolean, + val avatarURLString: String?, + val lastMessage: RoomMessage?, + val lastMessageTimestamp: Long?, + val unreadNotificationCount: Int, + val inviter: RoomMember? = null, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt new file mode 100644 index 0000000000..d677d56ed9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration + +interface RoomSummaryDataSource { + + sealed class LoadingState { + object NotLoaded : LoadingState() + data class Loaded(val numberOfRooms: Int) : LoadingState() + } + + fun updateAllRoomsVisibleRange(range: IntRange) + fun allRoomsLoadingState(): StateFlow<LoadingState> + fun allRooms(): StateFlow<List<RoomSummary>> + fun inviteRooms(): StateFlow<List<RoomSummary>> +} + +suspend fun RoomSummaryDataSource.awaitAllRoomsAreLoaded(timeout: Duration = Duration.INFINITE) { + try { + Timber.d("awaitAllRoomsAreLoaded: wait") + withTimeout(timeout) { + allRoomsLoadingState().firstOrNull { + it is RoomSummaryDataSource.LoadingState.Loaded + } + } + } catch (timeoutException: TimeoutCancellationException) { + Timber.d("awaitAllRoomsAreLoaded: no response after $timeout") + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt new file mode 100644 index 0000000000..50cde59b37 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room + +enum class StateEventType { + POLICY_RULE_ROOM, + POLICY_RULE_SERVER, + POLICY_RULE_USER, + ROOM_ALIASES, + ROOM_AVATAR, + ROOM_CANONICAL_ALIAS, + ROOM_CREATE, + ROOM_ENCRYPTION, + ROOM_GUEST_ACCESS, + ROOM_HISTORY_VISIBILITY, + ROOM_JOIN_RULES, + ROOM_MEMBER_EVENT, + ROOM_NAME, + ROOM_PINNED_EVENTS, + ROOM_POWER_LEVELS, + ROOM_SERVER_ACL, + ROOM_THIRD_PARTY_INVITE, + ROOM_TOMBSTONE, + ROOM_TOPIC, + SPACE_CHILD, + SPACE_PARENT; +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt new file mode 100644 index 0000000000..24375a45d4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room.location + +enum class AssetType { + SENDER, + PIN +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt new file mode 100644 index 0000000000..b778cad6a3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/message/RoomMessage.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room.message + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +data class RoomMessage( + val eventId: EventId, + val event: EventTimelineItem, + val sender: UserId, + val originServerTs: Long, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt new file mode 100644 index 0000000000..852401bffc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.room.powerlevels + +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType + +/** + * Shortcut for calling [MatrixRoom.canUserInvite] with our own user. + */ +suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId) + +/** + * Shortcut for calling [MatrixRoom.canUserSendState] with our own user. + */ +suspend fun MatrixRoom.canSendState(type: StateEventType): Result<Boolean> = canUserSendState(sessionId, type) + +/** + * Shortcut for calling [MatrixRoom.canUserSendMessage] with our own user. + */ +suspend fun MatrixRoom.canSendMessage(type: MessageEventType): Result<Boolean> = canUserSendMessage(sessionId, type) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt new file mode 100644 index 0000000000..5271ec9bc0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.sync + +import kotlinx.coroutines.flow.StateFlow + +interface SyncService { + /** + * Tries to start the sync. If already syncing it has no effect. + */ + suspend fun startSync(): Result<Unit> + + /** + * Tries to stop the sync. If service is not syncing it has no effect. + */ + fun stopSync(): Result<Unit> + + /** + * Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes. + */ + val syncState: StateFlow<SyncState> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncState.kt new file mode 100644 index 0000000000..9df542be73 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.sync + +enum class SyncState { + Idle, + Running, + Error, + Terminated, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt new file mode 100644 index 0000000000..af411216e5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimeline.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface MatrixTimeline { + + data class PaginationState( + val isBackPaginating: Boolean, + val hasMoreToLoadBackwards: Boolean + ) { + val canBackPaginate = !isBackPaginating && hasMoreToLoadBackwards + } + + val paginationState: StateFlow<PaginationState> + val timelineItems: Flow<List<MatrixTimelineItem>> + + suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> + suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> + + suspend fun sendReadReceipt(eventId: EventId): Result<Unit> +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt new file mode 100644 index 0000000000..38974b4002 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +sealed interface MatrixTimelineItem { + data class Event(val uniqueId: Long, val event: EventTimelineItem) : MatrixTimelineItem { + val eventId: EventId? = event.eventId + val transactionId: TransactionId? = event.transactionId + } + + data class Virtual(val uniqueId: Long, val virtual: VirtualTimelineItem) : MatrixTimelineItem + object Other : MatrixTimelineItem +} + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt new file mode 100644 index 0000000000..b7c155a5aa --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline + +sealed class TimelineException : Exception() { + object CannotPaginate : TimelineException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/TimelineItemDebugInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/TimelineItemDebugInfo.kt new file mode 100644 index 0000000000..b7292ff907 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/TimelineItemDebugInfo.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TimelineItemDebugInfo( + val model: String, + val originalJson: String?, + val latestEditedJson: String?, +) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt new file mode 100644 index 0000000000..203fb30794 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.VideoInfo + +sealed interface EventContent + +data class MessageContent( + val body: String, + val inReplyTo: InReplyTo?, + val isEdited: Boolean, + val type: MessageType? +) : EventContent + + +sealed interface InReplyTo { + /** The event details are not loaded yet. We can fetch them. */ + data class NotLoaded(val eventId: EventId) : InReplyTo + + /** The event details are pending to be fetched. We should **not** fetch them again. */ + object Pending : InReplyTo + + /** The event details are available. */ + data class Ready( + val eventId: EventId, + val content: MessageContent, + val senderId: UserId, + val senderDisplayName: String?, + val senderAvatarUrl: String?, + ) : InReplyTo + + /** + * Fetching the event details failed. + * + * We can try to fetch them again **with a proper retry strategy**, but not blindly: + * + * If the reason for the failure is consistent on the server, we'd enter a loop + * where we keep trying to fetch the same event. + * */ + object Error : InReplyTo +} + +object RedactedContent : EventContent + +data class StickerContent( + val body: String, + val info: ImageInfo, + val url: String +) : EventContent + +data class UnableToDecryptContent( + val data: Data +) : EventContent { + sealed interface Data { + data class OlmV1Curve25519AesSha2( + val senderKey: String + ) : Data + + data class MegolmV1AesSha2( + val sessionId: String + ) : Data + + object Unknown : Data + } +} + +data class RoomMembershipContent( + val userId: UserId, + val change: MembershipChange? +) : EventContent + +data class ProfileChangeContent( + val displayName: String?, + val prevDisplayName: String?, + val avatarUrl: String?, + val prevAvatarUrl: String? +) : EventContent + +data class StateContent( + val stateKey: String, + val content: OtherState +) : EventContent + +data class FailedToParseMessageLikeContent( + val eventType: String, + val error: String +) : EventContent + +data class FailedToParseStateContent( + val eventType: String, + val stateKey: String, + val error: String +) : EventContent + +object UnknownContent : EventContent + +sealed interface MessageType + +object UnknownMessageType : MessageType + +enum class MessageFormat { + HTML, UNKNOWN +} + +data class FormattedBody( + val format: MessageFormat, + val body: String +) + +data class EmoteMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +data class ImageMessageType( + val body: String, + val source: MediaSource, + val info: ImageInfo? +) : MessageType + +data class LocationMessageType( + val body: String, + val geoUri: String, + val description: String?, +) : MessageType + +data class AudioMessageType( + val body: String, + val source: MediaSource, + val info: AudioInfo? +) : MessageType + +data class VideoMessageType( + val body: String, + val source: MediaSource, + val info: VideoInfo? +) : MessageType + +data class FileMessageType( + val body: String, + val source: MediaSource, + val info: FileInfo? +) : MessageType + +data class NoticeMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +data class TextMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +enum class MembershipChange { + NONE, + ERROR, + JOINED, + LEFT, + BANNED, + UNBANNED, + KICKED, + INVITED, + KICKED_AND_BANNED, + INVITATION_ACCEPTED, + INVITATION_REJECTED, + INVITATION_REVOKED, + KNOCKED, + KNOCK_ACCEPTED, + KNOCK_RETRACTED, + KNOCK_DENIED, + NOT_IMPLEMENTED; +} + +sealed interface OtherState { + object PolicyRuleRoom : OtherState + + object PolicyRuleServer : OtherState + + object PolicyRuleUser : OtherState + + object RoomAliases : OtherState + + data class RoomAvatar( + val url: String? + ) : OtherState + + object RoomCanonicalAlias : OtherState + + object RoomCreate : OtherState + + object RoomEncryption : OtherState + + object RoomGuestAccess : OtherState + + object RoomHistoryVisibility : OtherState + + object RoomJoinRules : OtherState + + data class RoomName( + val name: String? + ) : OtherState + + object RoomPinnedEvents : OtherState + + object RoomPowerLevels : OtherState + + object RoomServerAcl : OtherState + + data class RoomThirdPartyInvite( + val displayName: String? + ) : OtherState + + object RoomTombstone : OtherState + + data class RoomTopic( + val topic: String? + ) : OtherState + + object SpaceChild : OtherState + + object SpaceParent : OtherState + + data class Custom( + val eventType: String + ) : OtherState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt new file mode 100644 index 0000000000..8bea4b5330 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId + +data class EventReaction( + val key: String, + val count: Long, + val senderIds: List<UserId> +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt new file mode 100644 index 0000000000..50bf5f8ce5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +data class EventTimelineItem( + val eventId: EventId?, + val transactionId: TransactionId?, + val isEditable: Boolean, + val isLocal: Boolean, + val isOwn: Boolean, + val isRemote: Boolean, + val localSendState: LocalEventSendState?, + val reactions: List<EventReaction>, + val sender: UserId, + val senderProfile: ProfileTimelineDetails, + val timestamp: Long, + val content: EventContent, + val debugInfo: TimelineItemDebugInfo, + val origin: TimelineItemEventOrigin?, +) { + fun inReplyTo(): InReplyTo? { + return (content as? MessageContent)?.inReplyTo + } + fun hasNotLoadedInReplyTo(): Boolean { + val details = inReplyTo() + return details is InReplyTo.NotLoaded + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventType.kt new file mode 100644 index 0000000000..8141528f34 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventType.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +/** + * Constants defining known event types from Matrix specifications. + */ +object EventType { + const val PRESENCE = "m.presence" + const val MESSAGE = "m.room.message" + const val STICKER = "m.sticker" + const val ENCRYPTED = "m.room.encrypted" + const val FEEDBACK = "m.room.message.feedback" + const val TYPING = "m.typing" + const val REDACTION = "m.room.redaction" + const val RECEIPT = "m.receipt" + const val ROOM_KEY = "m.room_key" + const val PLUMBING = "m.room.plumbing" + const val BOT_OPTIONS = "m.room.bot.options" + const val PREVIEW_URLS = "org.matrix.room.preview_urls" + + // State Events + + const val STATE_ROOM_WIDGET_LEGACY = "im.vector.modular.widgets" + const val STATE_ROOM_WIDGET = "m.widget" + const val STATE_ROOM_NAME = "m.room.name" + const val STATE_ROOM_TOPIC = "m.room.topic" + const val STATE_ROOM_AVATAR = "m.room.avatar" + const val STATE_ROOM_MEMBER = "m.room.member" + const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite" + const val STATE_ROOM_CREATE = "m.room.create" + const val STATE_ROOM_JOIN_RULES = "m.room.join_rules" + const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access" + const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels" + + const val STATE_SPACE_CHILD = "m.space.child" + const val STATE_SPACE_PARENT = "m.space.parent" + + /** + * Note that this Event has been deprecated, see + * - https://matrix.org/docs/spec/client_server/r0.6.1#historical-events + * - https://github.com/matrix-org/matrix-doc/pull/2432 + */ + const val STATE_ROOM_ALIASES = "m.room.aliases" + const val STATE_ROOM_TOMBSTONE = "m.room.tombstone" + const val STATE_ROOM_CANONICAL_ALIAS = "m.room.canonical_alias" + const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility" + const val STATE_ROOM_RELATED_GROUPS = "m.room.related_groups" + const val STATE_ROOM_PINNED_EVENT = "m.room.pinned_events" + const val STATE_ROOM_ENCRYPTION = "m.room.encryption" + const val STATE_ROOM_SERVER_ACL = "m.room.server_acl" + + // Call Events + const val CALL_INVITE = "m.call.invite" + const val CALL_CANDIDATES = "m.call.candidates" + const val CALL_ANSWER = "m.call.answer" + const val CALL_SELECT_ANSWER = "m.call.select_answer" + const val CALL_NEGOTIATE = "m.call.negotiate" + const val CALL_REJECT = "m.call.reject" + const val CALL_HANGUP = "m.call.hangup" + + // This type is not processed by the client, just sent to the server + const val CALL_REPLACES = "m.call.replaces" + + // Key share events + const val ROOM_KEY_REQUEST = "m.room_key_request" + const val FORWARDED_ROOM_KEY = "m.forwarded_room_key" + + const val REQUEST_SECRET = "m.secret.request" + const val SEND_SECRET = "m.secret.send" + + // Relation Events + const val REACTION = "m.reaction" + + fun isCallEvent(type: String): Boolean { + return type == CALL_INVITE || + type == CALL_CANDIDATES || + type == CALL_ANSWER || + type == CALL_HANGUP || + type == CALL_SELECT_ANSWER || + type == CALL_NEGOTIATE || + type == CALL_REJECT || + type == CALL_REPLACES + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt new file mode 100644 index 0000000000..3e1ee55318 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface LocalEventSendState { + object NotSentYet : LocalEventSendState + object Canceled : LocalEventSendState + + data class SendingFailed( + val error: String + ) : LocalEventSendState + + data class Sent( + val eventId: EventId + ) : LocalEventSendState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetails.kt new file mode 100644 index 0000000000..fa22d3cf54 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetails.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +sealed interface ProfileTimelineDetails { + object Unavailable : ProfileTimelineDetails + + object Pending : ProfileTimelineDetails + + data class Ready( + val displayName: String?, + val displayNameAmbiguous: Boolean, + val avatarUrl: String? + ) : ProfileTimelineDetails + + data class Error( + val message: String + ) : ProfileTimelineDetails +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt new file mode 100644 index 0000000000..0f906e6719 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.event + +enum class TimelineItemEventOrigin { + LOCAL, SYNC, PAGINATION; +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt new file mode 100644 index 0000000000..11fd8b9c63 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.timeline.item.virtual + +sealed interface VirtualTimelineItem { + + data class DayDivider( + val timestamp: Long + ) : VirtualTimelineItem + + object ReadMarker : VirtualTimelineItem + + object EncryptedHistoryBanner : VirtualTimelineItem +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt new file mode 100644 index 0000000000..8bcd602b9f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.tracing + +data class TracingConfiguration( + val overrides: Map<Target, LogLevel> = emptyMap() +) { + + // Order should matters + private val targets: MutableMap<Target, LogLevel> = mutableMapOf( + Target.Common to LogLevel.Warn, + Target.Hyper to LogLevel.Warn, + Target.Sled to LogLevel.Warn, + Target.MatrixSdk.Root to LogLevel.Warn, + Target.MatrixSdk.Sled to LogLevel.Warn, + Target.MatrixSdk.Crypto to LogLevel.Debug, + Target.MatrixSdk.HttpClient to LogLevel.Debug, + Target.MatrixSdk.SlidingSync to LogLevel.Trace, + Target.MatrixSdk.BaseSlidingSync to LogLevel.Trace, + ) + + val filter: String + get() { + overrides.forEach { (target, logLevel) -> + targets[target] = logLevel + } + return targets.map { + if (it.key.filter.isEmpty()) { + it.value.filter + } else { + "${it.key.filter}=${it.value.filter}" + } + }.joinToString(separator = ",") + } +} + +sealed class Target(open val filter: String) { + object Common : Target("") + object Hyper : Target("hyper") + object Sled : Target("sled") + sealed class MatrixSdk(override val filter: String) : Target(filter) { + object Root : MatrixSdk("matrix_sdk") + object Sled : MatrixSdk("matrix_sdk_sled") + object Crypto: MatrixSdk("matrix_sdk_crypto") + object FFI : MatrixSdk("matrix_sdk_ffi") + object HttpClient : MatrixSdk("matrix_sdk::http_client") + object UniffiAPI : MatrixSdk("matrix_sdk_ffi::uniffi_api") + object SlidingSync : MatrixSdk("matrix_sdk::sliding_sync") + object BaseSlidingSync : MatrixSdk("matrix_sdk_base::sliding_sync") + } +} + +sealed class LogLevel(val filter: String) { + object Warn : LogLevel("warn") + object Trace : LogLevel("trace") + object Info : LogLevel("info") + object Debug : LogLevel("debug") + object Error : LogLevel("error") +} + +object TracingConfigurations { + val release = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info)) + val debug = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info)) + + fun custom(overrides: Map<Target, LogLevel>) = TracingConfiguration(overrides) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/CurrentUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/CurrentUser.kt new file mode 100644 index 0000000000..3968b058d9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/CurrentUser.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.user + +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Get the current user, as [MatrixUser], using [MatrixClient.loadUserAvatarURLString] + * and [MatrixClient.loadUserDisplayName]. + */ +suspend fun MatrixClient.getCurrentUser(): MatrixUser { + val userAvatarUrl = loadUserAvatarURLString().getOrNull() + val userDisplayName = loadUserDisplayName().getOrNull() + return MatrixUser( + userId = sessionId, + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixSearchUserResults.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixSearchUserResults.kt new file mode 100644 index 0000000000..a63a31b51f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixSearchUserResults.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.user + +data class MatrixSearchUserResults( + val results: List<MatrixUser>, + val limited: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt new file mode 100644 index 0000000000..9880557e33 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.user + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MatrixUser( + val userId: UserId, + val displayName: String? = null, + val avatarUrl: String? = null +) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt new file mode 100644 index 0000000000..b2f79c0750 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.verification + +import kotlinx.coroutines.flow.StateFlow + +interface SessionVerificationService { + + /** + * State of the current verification flow ([VerificationFlowState.Initial] if not started). + */ + val verificationFlowState : StateFlow<VerificationFlowState> + + /** + * The internal service that checks verification can only run after the initial sync. + * This [StateFlow] will notify consumers when the service is ready to be used. + */ + val isReady: StateFlow<Boolean> + + /** + * Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified] + * or [SessionVerifiedStatus.Verified]. + */ + val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> + + /** + * Request verification of the current session. + */ + suspend fun requestVerification() + + /** + * Cancels the current verification attempt. + */ + suspend fun cancelVerification() + + /** + * Approves the current verification. This must happen on both devices to successfully verify a session. + */ + suspend fun approveVerification() + + /** + * Declines the verification attempt because the user could not verify or does not trust the other side of the verification. + */ + suspend fun declineVerification() + + /** + * Starts the verification of the unverified session from another device. + */ + suspend fun startVerification() + + /** + * Returns the verification service state to the initial step. + */ + suspend fun reset() +} + +/** Verification status of the current session. */ +sealed interface SessionVerifiedStatus { + /** Unknown status, we couldn't read the actual value from the SDK. */ + object Unknown : SessionVerifiedStatus + + /** Not verified session status. */ + object NotVerified : SessionVerifiedStatus + + /** Verified session status. */ + object Verified : SessionVerifiedStatus +} + +/** States produced by the [SessionVerificationService]. */ +sealed interface VerificationFlowState { + /** Initial state. */ + object Initial : VerificationFlowState + + /** Session verification request was accepted by another device. */ + object AcceptedVerificationRequest : VerificationFlowState + + /** Short Authentication String (SAS) verification started between the 2 devices. */ + object StartedSasVerification : VerificationFlowState + + /** Verification data for the SAS verification (emojis) received. */ + data class ReceivedVerificationData(val emoji: List<VerificationEmoji>) : VerificationFlowState + + /** Verification completed successfully. */ + object Finished : VerificationFlowState + + /** Verification was cancelled by either device. */ + object Canceled : VerificationFlowState + + /** Verification failed with an error. */ + object Failed : VerificationFlowState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt new file mode 100644 index 0000000000..43b89b6587 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationEmoji.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.verification + +data class VerificationEmoji( + val code: String, + val name: String, +) diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTests.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTests.kt new file mode 100644 index 0000000000..b3ccf5264d --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTests.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.api.auth + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AuthErrorCodeTests { + + @Test + fun `errorCode finds UNKNOWN code`() { + val error = AuthenticationException.Generic("M_UNKNOWN") + assertThat(error.errorCode).isEqualTo(AuthErrorCode.UNKNOWN) + } + + @Test + fun `errorCode finds USER_DEACTIVATED code`() { + val error = AuthenticationException.Generic("M_USER_DEACTIVATED") + assertThat(error.errorCode).isEqualTo(AuthErrorCode.USER_DEACTIVATED) + } + + @Test + fun `errorCode finds FORBIDDEN code`() { + val error = AuthenticationException.Generic("M_FORBIDDEN") + assertThat(error.errorCode).isEqualTo(AuthErrorCode.FORBIDDEN) + } + + @Test + fun `errorCode cannot find code so it returns UNKNOWN`() { + val error = AuthenticationException.Generic("Some other error") + assertThat(error.errorCode).isEqualTo(AuthErrorCode.UNKNOWN) + } + +} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts new file mode 100644 index 0000000000..7786a3ee3f --- /dev/null +++ b/libraries/matrix/impl/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.22" +} + +android { + namespace = "io.element.android.libraries.matrix.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + // api(projects.libraries.rustsdk) + implementation(libs.matrix.sdk) + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.network) + implementation(projects.services.toolbox.api) + api(projects.libraries.matrix.api) + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation("net.java.dev.jna:jna:5.13.0@aar") + implementation(libs.androidx.datastore.preferences) + implementation(libs.serialization.json) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/matrix/impl/src/main/AndroidManifest.xml b/libraries/matrix/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..dc2b81fddc --- /dev/null +++ b/libraries/matrix/impl/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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" /> + +</manifest> diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt new file mode 100644 index 0000000000..640e0772a9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl + +import io.element.android.libraries.androidutils.file.getSizeOfFiles +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters +import io.element.android.libraries.matrix.api.createroom.RoomPreset +import io.element.android.libraries.matrix.api.createroom.RoomVisibility +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.room.awaitAllRoomsAreLoaded +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.impl.core.toProgressWatcher +import io.element.android.libraries.matrix.impl.media.RustMediaLoader +import io.element.android.libraries.matrix.impl.notification.RustNotificationService +import io.element.android.libraries.matrix.impl.pushers.RustPushersService +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder +import io.element.android.libraries.matrix.impl.room.RustMatrixRoom +import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource +import io.element.android.libraries.matrix.impl.room.roomOrNull +import io.element.android.libraries.matrix.impl.room.stateFlow +import io.element.android.libraries.matrix.impl.sync.RustSyncService +import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper +import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper +import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters +import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset +import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility +import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService + +@OptIn(ExperimentalCoroutinesApi::class) +class RustMatrixClient constructor( + private val client: Client, + private val syncService: ClientSyncService, + private val sessionStore: SessionStore, + private val appCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + private val baseDirectory: File, + baseCacheDirectory: File, + private val clock: SystemClock, +) : MatrixClient { + + override val sessionId: UserId = UserId(client.userId()) + private val roomListService = syncService.roomListService() + private val sessionDispatcher = dispatchers.io.limitedParallelism(64) + private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}") + private val verificationService = RustSessionVerificationService() + private val rustSyncService = RustSyncService(syncService, roomListService.stateFlow(), sessionCoroutineScope) + private val pushersService = RustPushersService( + client = client, + dispatchers = dispatchers, + ) + private val notificationClient = client.notificationClient().use { builder -> + builder.finish() + } + + private val notificationService = RustNotificationService(notificationClient) + + private val isLoggingOut = AtomicBoolean(false) + + private val clientDelegate = object : ClientDelegate { + override fun didReceiveAuthError(isSoftLogout: Boolean) { + Timber.w("didReceiveAuthError(isSoftLogout=$isSoftLogout)") + if (isLoggingOut.getAndSet(true).not()) { + Timber.v("didReceiveAuthError -> do the cleanup") + //TODO handle isSoftLogout parameter. + appCoroutineScope.launch { + doLogout(doRequest = false) + } + } else { + Timber.v("didReceiveAuthError -> already cleaning up") + } + } + } + + private val rustRoomSummaryDataSource: RustRoomSummaryDataSource = + RustRoomSummaryDataSource( + roomListService = roomListService, + sessionCoroutineScope = sessionCoroutineScope, + dispatcher = sessionDispatcher, + ) + + override val roomSummaryDataSource: RoomSummaryDataSource + get() = rustRoomSummaryDataSource + + private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client) + override val mediaLoader: MatrixMediaLoader + get() = rustMediaLoader + + private val roomMembershipObserver = RoomMembershipObserver() + + private val roomContentForwarder = RoomContentForwarder(roomListService) + + init { + client.setDelegate(clientDelegate) + rustSyncService.syncState + .onEach { syncState -> + if (syncState == SyncState.Running) { + onSlidingSyncUpdate() + } + }.launchIn(sessionCoroutineScope) + } + + override suspend fun getRoom(roomId: RoomId): MatrixRoom? { + // Check if already in memory... + var cachedPairOfRoom = pairOfRoom(roomId) + if (cachedPairOfRoom == null) { + //... otherwise, lets wait for the SS to load all rooms and check again. + roomSummaryDataSource.awaitAllRoomsAreLoaded() + cachedPairOfRoom = pairOfRoom(roomId) + } + if (cachedPairOfRoom == null) return null + val (roomListItem, fullRoom) = cachedPairOfRoom + return RustMatrixRoom( + sessionId = sessionId, + roomListItem = roomListItem, + innerRoom = fullRoom, + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = dispatchers, + systemClock = clock, + roomContentForwarder = roomContentForwarder, + sessionData = sessionStore.getSession(sessionId.value)!!, + ) + } + + private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? = withContext(sessionDispatcher) { + val cachedRoomListItem = roomListService.roomOrNull(roomId.value) + val fullRoom = cachedRoomListItem?.fullRoom() + if (cachedRoomListItem == null || fullRoom == null) { + Timber.d("No room cached for $roomId") + null + } else { + Timber.d("Found room cached for $roomId") + Pair(cachedRoomListItem, fullRoom) + } + } + + override suspend fun findDM(userId: UserId): MatrixRoom? { + val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) } + return roomId?.let { getRoom(it) } + } + + override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) { + runCatching { + client.ignoreUser(userId.value) + } + } + + override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) { + runCatching { + client.unignoreUser(userId.value) + } + } + + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(sessionDispatcher) { + runCatching { + val rustParams = RustCreateRoomParameters( + name = createRoomParams.name, + topic = createRoomParams.topic, + isEncrypted = createRoomParams.isEncrypted, + isDirect = createRoomParams.isDirect, + visibility = when (createRoomParams.visibility) { + RoomVisibility.PUBLIC -> RustRoomVisibility.PUBLIC + RoomVisibility.PRIVATE -> RustRoomVisibility.PRIVATE + }, + preset = when (createRoomParams.preset) { + RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT + RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT + RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT + }, + invite = createRoomParams.invite?.map { it.value }, + avatar = createRoomParams.avatar, + ) + val roomId = RoomId(client.createRoom(rustParams)) + + // Wait to receive the room back from the sync + withTimeout(30_000L) { + roomSummaryDataSource.allRooms() + .filter { roomSummaries -> + roomSummaries.map { it.identifier() }.contains(roomId.value) + }.first() + } + roomId + } + } + + override suspend fun createDM(userId: UserId): Result<RoomId> { + val createRoomParams = CreateRoomParameters( + name = null, + isEncrypted = true, + isDirect = true, + visibility = RoomVisibility.PRIVATE, + preset = RoomPreset.TRUSTED_PRIVATE_CHAT, + invite = listOf(userId) + ) + return createRoom(createRoomParams) + } + + override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(sessionDispatcher) { + runCatching { + client.getProfile(userId.value).let(UserProfileMapper::map) + } + } + + override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> = + withContext(sessionDispatcher) { + runCatching { + client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map) + } + } + + override fun syncService(): SyncService = rustSyncService + + override fun sessionVerificationService(): SessionVerificationService = verificationService + + override fun pushersService(): PushersService = pushersService + + override fun notificationService(): NotificationService = notificationService + + override fun close() { + sessionCoroutineScope.cancel() + client.setDelegate(null) + verificationService.destroy() + syncService.destroy() + roomListService.destroy() + notificationClient.destroy() + client.destroy() + } + + override suspend fun getCacheSize(): Long { + // Do not use client.userId since it can throw if client has been closed (during clear cache) + return baseDirectory.getCacheSize(userID = sessionId.value) + } + + override suspend fun clearCache() { + close() + baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false) + } + + override suspend fun logout() = doLogout(doRequest = true) + + private suspend fun doLogout(doRequest: Boolean) = withContext(sessionDispatcher) { + if (doRequest) { + try { + client.logout() + } catch (failure: Throwable) { + Timber.e(failure, "Fail to call logout on HS. Still delete local files.") + } + } + close() + baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = true) + sessionStore.removeSession(sessionId.value) + } + + override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) { + runCatching { + client.displayName() + } + } + + override suspend fun loadUserAvatarURLString(): Result<String?> = withContext(sessionDispatcher) { + runCatching { + client.avatarUrl() + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> = withContext(sessionDispatcher) { + runCatching { + client.uploadMedia(mimeType, data.toUByteArray().toList(), progressCallback?.toProgressWatcher()) + } + } + + private fun onSlidingSyncUpdate() { + if (!verificationService.isReady.value) { + try { + verificationService.verificationController = client.getSessionVerificationController() + } catch (e: Throwable) { + Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.") + } + } + } + + override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver + + private suspend fun File.getCacheSize( + userID: String, + includeCryptoDb: Boolean = false, + ): Long = withContext(sessionDispatcher) { + // Rust sanitises the user ID replacing invalid characters with an _ + val sanitisedUserID = userID.replace(":", "_") + val sessionDirectory = File(this@getCacheSize, sanitisedUserID) + if (includeCryptoDb) { + sessionDirectory.getSizeOfFiles() + } else { + listOf( + "matrix-sdk-state.sqlite3", + "matrix-sdk-state.sqlite3-shm", + "matrix-sdk-state.sqlite3-wal", + ).map { fileName -> + File(sessionDirectory, fileName) + }.sumOf { file -> + file.length() + } + } + } + + private suspend fun File.deleteSessionDirectory( + userID: String, + deleteCryptoDb: Boolean = false, + ): Boolean = withContext(sessionDispatcher) { + // Rust sanitises the user ID replacing invalid characters with an _ + val sanitisedUserID = userID.replace(":", "_") + val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID) + if (deleteCryptoDb) { + // Delete the folder and all its content + sessionDirectory.deleteRecursively() + } else { + // Delete only the state.db file + sessionDirectory.listFiles().orEmpty() + .filter { it.name.contains("matrix-sdk-state") } + .forEach { file -> + Timber.w("Deleting file ${file.name}...") + file.safeDelete() + } + true + } + } +} + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt new file mode 100644 index 0000000000..f0feb2857d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import org.matrix.rustcomponents.sdk.AuthenticationException as RustAuthenticationException + +fun Throwable.mapAuthenticationException(): AuthenticationException { + return when (this) { + is RustAuthenticationException.ClientMissing -> AuthenticationException.ClientMissing(this.message!!) + is RustAuthenticationException.Generic -> AuthenticationException.Generic(this.message!!) + is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!) + is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!) + is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!) + + /* TODO Oidc + is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!) + is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!) + is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!) + is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!) + is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!) + */ + + else -> AuthenticationException.Generic(this.message ?: "Unknown error") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt new file mode 100644 index 0000000000..a3d277c6da --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import org.matrix.rustcomponents.sdk.HomeserverLoginDetails + +fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use { + MatrixHomeServerDetails( + url = url(), + supportsPasswordLogin = supportsPasswordLogin(), + supportsOidcLogin = false // TODO Oidc supportsOidcLogin(), + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt new file mode 100644 index 0000000000..1ba5063df9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfig.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.api.auth.OidcConfig +// TODO Oidc +// import org.matrix.rustcomponents.sdk.OidcClientMetadata + +/* +val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata( + clientName = "Element", + redirectUri = OidcConfig.redirectUri, + clientUri = "https://element.io", + tosUri = "https://element.io/user-terms-of-service", + policyUri = "https://element.io/privacy" +) + */ + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt new file mode 100644 index 0000000000..0bc299d020 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.auth + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.RustMatrixClient +import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +// TODO Oidc +// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.use +import java.io.File +import java.util.Date +import javax.inject.Inject +import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class RustMatrixAuthenticationService @Inject constructor( + @ApplicationContext private val context: Context, + private val baseDirectory: File, + private val appCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, + private val sessionStore: SessionStore, + private val clock: SystemClock, + private val userAgentProvider: UserAgentProvider, +) : MatrixAuthenticationService { + + private val authService: RustAuthenticationService = RustAuthenticationService( + basePath = baseDirectory.absolutePath, + passphrase = null, + // TODO Oidc + // oidcClientMetadata = oidcClientMetadata, + userAgent = userAgentProvider.provide(), + customSlidingSyncProxy = null, + ) + private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) + + override fun isLoggedIn(): Flow<Boolean> { + return sessionStore.isLoggedIn() + } + + override suspend fun getLatestSessionId(): SessionId? = withContext(coroutineDispatchers.io) { + sessionStore.getLatestSession()?.userId?.let { SessionId(it) } + } + + override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> = withContext(coroutineDispatchers.io) { + runCatching { + val sessionData = sessionStore.getSession(sessionId.value) + if (sessionData != null) { + val client = ClientBuilder() + .basePath(baseDirectory.absolutePath) + .homeserverUrl(sessionData.homeserverUrl) + .username(sessionData.userId) + .userAgent(userAgentProvider.provide()) + .use { it.build() } + client.restoreSession(sessionData.toSession()) + createMatrixClient(client) + } else { + throw IllegalStateException("No session to restore with id $sessionId") + } + }.mapFailure { failure -> + failure.mapClientException() + } + } + + override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> = currentHomeserver + + override suspend fun setHomeserver(homeserver: String): Result<Unit> = + withContext(coroutineDispatchers.io) { + runCatching { + authService.configureHomeserver(homeserver) + val homeServerDetails = authService.homeserverDetails()?.map() + if (homeServerDetails != null) { + currentHomeserver.value = homeServerDetails.copy(url = homeserver) + } + }.mapFailure { failure -> + failure.mapAuthenticationException() + } + } + + override suspend fun login(username: String, password: String): Result<SessionId> = + withContext(coroutineDispatchers.io) { + runCatching { + val client = authService.login(username, password, "Element X Android", null) + val sessionData = client.use { it.session().toSessionData() } + sessionStore.storeData(sessionData) + SessionId(sessionData.userId) + }.mapFailure { failure -> + failure.mapAuthenticationException() + } + } + + // TODO Oidc + // private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null + + override suspend fun getOidcUrl(): Result<OidcDetails> { + TODO("Oidc") + /* + return withContext(coroutineDispatchers.io) { + runCatching { + val urlForOidcLogin = authService.urlForOidcLogin() + val url = urlForOidcLogin.loginUrl() + pendingUrlForOidcLogin = urlForOidcLogin + OidcDetails(url) + }.mapFailure { failure -> + failure.mapAuthenticationException() + } + } + */ + } + + override suspend fun cancelOidcLogin(): Result<Unit> { + TODO("Oidc") + /* + return withContext(coroutineDispatchers.io) { + runCatching { + pendingUrlForOidcLogin?.close() + pendingUrlForOidcLogin = null + }.mapFailure { failure -> + failure.mapAuthenticationException() + } + } + */ + } + + /** + * callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters). + */ + override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> { + TODO("Oidc") + /* + return withContext(coroutineDispatchers.io) { + runCatching { + val urlForOidcLogin = pendingUrlForOidcLogin ?: error("You need to call `getOidcUrl()` first") + val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl) + val sessionData = client.use { it.session().toSessionData() } + pendingUrlForOidcLogin = null + sessionStore.storeData(sessionData) + SessionId(sessionData.userId) + }.mapFailure { failure -> + failure.mapAuthenticationException() + } + } + */ + } + + private suspend fun createMatrixClient(client: Client): MatrixClient { + val syncService = client.syncService().finish() + return RustMatrixClient( + client = client, + syncService = syncService, + sessionStore = sessionStore, + appCoroutineScope = appCoroutineScope, + dispatchers = coroutineDispatchers, + baseDirectory = baseDirectory, + baseCacheDirectory = context.cacheDir, + clock = clock, + ) + } +} + +private fun SessionData.toSession() = Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + deviceId = deviceId, + homeserverUrl = homeserverUrl, + slidingSyncProxy = slidingSyncProxy, +) + +private fun Session.toSessionData() = SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + slidingSyncProxy = slidingSyncProxy, + loginTimestamp = Date(), +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt new file mode 100644 index 0000000000..f904e13bd6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.core + +import io.element.android.libraries.matrix.api.core.ProgressCallback +import org.matrix.rustcomponents.sdk.ProgressWatcher +import org.matrix.rustcomponents.sdk.TransmissionProgress + +internal class ProgressWatcherWrapper(private val progressCallback: ProgressCallback) : ProgressWatcher { + override fun transmissionProgress(progress: TransmissionProgress) { + progressCallback.onProgress(progress.current.toLong(), progress.total.toLong()) + } +} + +internal fun ProgressCallback.toProgressWatcher(): ProgressWatcher { + return ProgressWatcherWrapper(this) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt new file mode 100644 index 0000000000..bf260be6ec --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.verification.SessionVerificationService + +@Module +@ContributesTo(SessionScope::class) +object SessionMatrixModule { + @Provides + fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { + return matrixClient.sessionVerificationService() + } + + @Provides + fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { + return matrixClient.roomMembershipObserver() + } + + @Provides + fun provideRoomSummaryDataSource(matrixClient: MatrixClient): RoomSummaryDataSource { + return matrixClient.roomSummaryDataSource + } + + @Provides + fun provideMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader { + return matrixClient.mediaLoader + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt new file mode 100644 index 0000000000..6efca88f9b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.exception + +import io.element.android.libraries.matrix.api.exception.ClientException +import org.matrix.rustcomponents.sdk.ClientException as RustClientException + +fun Throwable.mapClientException(): ClientException { + return when (this) { + is RustClientException.Generic -> ClientException.Generic(msg) + else -> ClientException.Other(message ?: "Unknown error") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt new file mode 100644 index 0000000000..70c3bac6ed --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.AudioInfo +import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo + +fun RustAudioInfo.map(): AudioInfo = AudioInfo( + duration = duration, + size = size?.toLong(), + mimetype = mimetype +) + +fun AudioInfo.map(): RustAudioInfo = RustAudioInfo( + duration = duration, + size = size?.toULong(), + mimetype = mimetype, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt new file mode 100644 index 0000000000..c287c9446f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.FileInfo +import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo + +fun RustFileInfo.map(): FileInfo = FileInfo( + mimetype = mimetype, + size = size?.toLong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = thumbnailSource?.map() +) + +fun FileInfo.map(): RustFileInfo = RustFileInfo( + mimetype = mimetype, + size = size?.toULong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = null +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt new file mode 100644 index 0000000000..b66cec96fd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.ImageInfo +import org.matrix.rustcomponents.sdk.MediaSource +import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo + +fun RustImageInfo.map(): ImageInfo = ImageInfo( + height = height?.toLong(), + width = width?.toLong(), + mimetype = mimetype, + size = size?.toLong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = thumbnailSource?.map(), + blurhash = blurhash +) + +fun ImageInfo.map(): RustImageInfo = RustImageInfo( + height = height?.toULong(), + width = width?.toULong(), + mimetype = mimetype, + size = size?.toULong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = null, + blurhash = blurhash, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt new file mode 100644 index 0000000000..c70bd0640f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.MediaSource +import org.matrix.rustcomponents.sdk.use +import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource + +fun RustMediaSource.map(): MediaSource = use { + MediaSource(it.url(), it.toJson()) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt new file mode 100644 index 0000000000..4b26b8c6c6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.MediaFile +import org.matrix.rustcomponents.sdk.MediaFileHandle + +class RustMediaFile(private val inner: MediaFileHandle) : MediaFile { + + override fun path(): String { + return inner.path() + } + + override fun close() { + inner.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt new file mode 100644 index 0000000000..c958810f5e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.mediaSourceFromUrl +import org.matrix.rustcomponents.sdk.use +import java.io.File +import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource + +class RustMediaLoader( + baseCacheDirectory: File, + dispatchers: CoroutineDispatchers, + private val innerClient: Client, +) : MatrixMediaLoader { + + @OptIn(ExperimentalCoroutinesApi::class) + private val mediaDispatcher = dispatchers.io.limitedParallelism(32) + private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply { + if (!exists()) { + mkdirs() + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = + withContext(mediaDispatcher) { + runCatching { + source.toRustMediaSource().use { source -> + innerClient.getMediaContent(source).toUByteArray().toByteArray() + } + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun loadMediaThumbnail( + source: MediaSource, + width: Long, + height: Long + ): Result<ByteArray> = + withContext(mediaDispatcher) { + runCatching { + source.toRustMediaSource().use { mediaSource -> + innerClient.getMediaThumbnail( + mediaSource = mediaSource, + width = width.toULong(), + height = height.toULong() + ).toUByteArray().toByteArray() + } + } + } + + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = + withContext(mediaDispatcher) { + runCatching { + source.toRustMediaSource().use { mediaSource -> + val mediaFile = innerClient.getMediaFile( + mediaSource = mediaSource, + body = body, + mimeType = mimeType ?: "application/octet-stream", + tempDir = cacheDirectory.path, + ) + RustMediaFile(mediaFile) + } + } + } + + private fun MediaSource.toRustMediaSource(): RustMediaSource { + val json = this.json + return if (json != null) { + RustMediaSource.fromJson(json) + } else { + mediaSourceFromUrl(url) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt new file mode 100644 index 0000000000..c3940ac967 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import org.matrix.rustcomponents.sdk.ThumbnailInfo as RustThumbnailInfo + +fun RustThumbnailInfo.map(): ThumbnailInfo = ThumbnailInfo( + height = height?.toLong(), + width = width?.toLong(), + mimetype = mimetype, + size = size?.toLong() +) + +fun ThumbnailInfo.map(): RustThumbnailInfo = RustThumbnailInfo( + height = height?.toULong(), + width = width?.toULong(), + mimetype = mimetype, + size = size?.toULong() +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt new file mode 100644 index 0000000000..661d1b9b33 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.VideoInfo +import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo + +fun RustVideoInfo.map(): VideoInfo = VideoInfo( + duration = duration, + height = height?.toLong(), + width = width?.toLong(), + mimetype = mimetype, + size = size?.toLong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = thumbnailSource?.map(), + blurhash = blurhash +) + +fun VideoInfo.map(): RustVideoInfo = RustVideoInfo( + duration = duration, + height = height?.toULong(), + width = width?.toULong(), + mimetype = mimetype, + size = size?.toULong(), + thumbnailInfo = thumbnailInfo?.map(), + thumbnailSource = null, + blurhash = blurhash +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt new file mode 100644 index 0000000000..07acb7fec5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.notification.NotificationData +import org.matrix.rustcomponents.sdk.NotificationItem +import org.matrix.rustcomponents.sdk.use + +class NotificationMapper { + private val timelineEventMapper = TimelineEventMapper() + + fun map(roomId: RoomId, notificationItem: NotificationItem): NotificationData { + return notificationItem.use { item -> + NotificationData( + senderId = UserId(item.event.senderId()), + eventId = EventId(item.event.eventId()), + roomId = roomId, + senderAvatarUrl = item.senderInfo.avatarUrl, + senderDisplayName = item.senderInfo.displayName, + roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { item.roomInfo.isDirect }, + roomDisplayName = item.roomInfo.displayName, + isDirect = item.roomInfo.isDirect, + isEncrypted = item.roomInfo.isEncrypted.orFalse(), + isNoisy = item.isNoisy, + event = item.event.use { event -> timelineEventMapper.map(event) } + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt new file mode 100644 index 0000000000..92c996049e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.notification.NotificationService +import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.use + +class RustNotificationService( + private val notificationClient: NotificationClient, +) : NotificationService { + private val notificationMapper: NotificationMapper = NotificationMapper() + + override fun getNotification( + userId: SessionId, + roomId: RoomId, + eventId: EventId, + filterByPushRules: Boolean, + ): Result<NotificationData?> { + return runCatching { + val item = notificationClient.getNotification(roomId.value, eventId.value) + item?.use { + notificationMapper.map(roomId, it) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt new file mode 100644 index 0000000000..f7d4a00188 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.matrix.api.notification.NotificationContent +import io.element.android.libraries.matrix.api.notification.NotificationEvent +import io.element.android.libraries.matrix.impl.room.RoomMemberMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper +import org.matrix.rustcomponents.sdk.MessageLikeEventContent +import org.matrix.rustcomponents.sdk.StateEventContent +import org.matrix.rustcomponents.sdk.TimelineEvent +import org.matrix.rustcomponents.sdk.TimelineEventType +import org.matrix.rustcomponents.sdk.use +import javax.inject.Inject + +class TimelineEventMapper @Inject constructor() { + + fun map(timelineEvent: TimelineEvent): NotificationEvent { + return timelineEvent.use { + NotificationEvent( + timestamp = it.timestamp().toLong(), + content = it.eventType().toContent(), + contentUrl = null // TODO it.eventType().toContentUrl(), + ) + } + } +} + +private fun TimelineEventType.toContent(): NotificationContent { + return when (this) { + is TimelineEventType.MessageLike -> content.toContent() + is TimelineEventType.State -> content.toContent() + } +} + +private fun StateEventContent.toContent(): NotificationContent.StateEvent { + return when (this) { + StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom + StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer + StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser + StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases + StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar + StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias + StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate + StateEventContent.RoomEncryption -> NotificationContent.StateEvent.RoomEncryption + StateEventContent.RoomGuestAccess -> NotificationContent.StateEvent.RoomGuestAccess + StateEventContent.RoomHistoryVisibility -> NotificationContent.StateEvent.RoomHistoryVisibility + StateEventContent.RoomJoinRules -> NotificationContent.StateEvent.RoomJoinRules + is StateEventContent.RoomMemberContent -> { + NotificationContent.StateEvent.RoomMemberContent(userId, RoomMemberMapper.mapMembership(membershipState)) + } + StateEventContent.RoomName -> NotificationContent.StateEvent.RoomName + StateEventContent.RoomPinnedEvents -> NotificationContent.StateEvent.RoomPinnedEvents + StateEventContent.RoomPowerLevels -> NotificationContent.StateEvent.RoomPowerLevels + StateEventContent.RoomServerAcl -> NotificationContent.StateEvent.RoomServerAcl + StateEventContent.RoomThirdPartyInvite -> NotificationContent.StateEvent.RoomThirdPartyInvite + StateEventContent.RoomTombstone -> NotificationContent.StateEvent.RoomTombstone + StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic + StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild + StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent + } +} + +private fun MessageLikeEventContent.toContent(): NotificationContent.MessageLike { + return use { + when (it) { + MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer + MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates + MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup + MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite + MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept + MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel + MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone + MessageLikeEventContent.KeyVerificationKey -> NotificationContent.MessageLike.KeyVerificationKey + MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac + MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady + MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart + is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(it.relatedEventId) + MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted + is MessageLikeEventContent.RoomMessage -> { + NotificationContent.MessageLike.RoomMessage(EventMessageMapper().mapMessageType(it.messageType)) + } + MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction + MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt new file mode 100644 index 0000000000..60ca4df311 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.pushers + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.HttpPusherData +import org.matrix.rustcomponents.sdk.PushFormat +import org.matrix.rustcomponents.sdk.PusherIdentifiers +import org.matrix.rustcomponents.sdk.PusherKind + +class RustPushersService( + private val client: Client, + private val dispatchers: CoroutineDispatchers +) : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit> { + return withContext(dispatchers.io) { + runCatching { + client.setPusher( + identifiers = PusherIdentifiers( + pushkey = setHttpPusherData.pushKey, + appId = setHttpPusherData.appId + ), + kind = PusherKind.Http( + data = HttpPusherData( + url = setHttpPusherData.url, + format = PushFormat.EVENT_ID_ONLY, + defaultPayload = setHttpPusherData.defaultPayload + ) + ), + appDisplayName = setHttpPusherData.appDisplayName, + deviceDisplayName = setHttpPusherData.deviceDisplayName, + profileTag = setHttpPusherData.profileTag, + lang = setHttpPusherData.lang + ) + } + } + } + + override suspend fun unsetHttpPusher(): Result<Unit> { + // TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK. + return Result.success(Unit) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt new file mode 100644 index 0000000000..a117c1d313 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.MessageEventType +import org.matrix.rustcomponents.sdk.MessageLikeEventType + +fun MessageEventType.map(): MessageLikeEventType = when (this) { + MessageEventType.CALL_ANSWER -> MessageLikeEventType.CALL_ANSWER + MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE + MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP + MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES + MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY + MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START + MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL + MessageEventType.KEY_VERIFICATION_ACCEPT -> MessageLikeEventType.KEY_VERIFICATION_ACCEPT + MessageEventType.KEY_VERIFICATION_KEY -> MessageLikeEventType.KEY_VERIFICATION_KEY + MessageEventType.KEY_VERIFICATION_MAC -> MessageLikeEventType.KEY_VERIFICATION_MAC + MessageEventType.KEY_VERIFICATION_DONE -> MessageLikeEventType.KEY_VERIFICATION_DONE + MessageEventType.REACTION_SENT -> MessageLikeEventType.REACTION_SENT + MessageEventType.ROOM_ENCRYPTED -> MessageLikeEventType.ROOM_ENCRYPTED + MessageEventType.ROOM_MESSAGE -> MessageLikeEventType.ROOM_MESSAGE + MessageEventType.ROOM_REDACTION -> MessageLikeEventType.ROOM_REDACTION + MessageEventType.STICKER -> MessageLikeEventType.STICKER +} + +fun MessageLikeEventType.map(): MessageEventType = when (this) { + MessageLikeEventType.CALL_ANSWER -> MessageEventType.CALL_ANSWER + MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE + MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP + MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES + MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY + MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START + MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL + MessageLikeEventType.KEY_VERIFICATION_ACCEPT -> MessageEventType.KEY_VERIFICATION_ACCEPT + MessageLikeEventType.KEY_VERIFICATION_KEY -> MessageEventType.KEY_VERIFICATION_KEY + MessageLikeEventType.KEY_VERIFICATION_MAC -> MessageEventType.KEY_VERIFICATION_MAC + MessageLikeEventType.KEY_VERIFICATION_DONE -> MessageEventType.KEY_VERIFICATION_DONE + MessageLikeEventType.REACTION_SENT -> MessageEventType.REACTION_SENT + MessageLikeEventType.ROOM_ENCRYPTED -> MessageEventType.ROOM_ENCRYPTED + MessageLikeEventType.ROOM_MESSAGE -> MessageEventType.ROOM_MESSAGE + MessageLikeEventType.ROOM_REDACTION -> MessageEventType.ROOM_REDACTION + MessageLikeEventType.STICKER -> MessageEventType.STICKER +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt new file mode 100644 index 0000000000..4e2d63d091 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.ForwardEventException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineListener +import org.matrix.rustcomponents.sdk.genTransactionId +import kotlin.time.Duration.Companion.milliseconds + +/** + * Helper to forward event contents from a room to a set of other rooms. + * @param roomListService the [RoomListService] to fetch room instances to forward the event to + */ +class RoomContentForwarder( + private val roomListService: RoomListService, +) { + + /** + * Forwards the event with the given [eventId] from the [fromRoom] to the given [toRoomIds]. + * @param fromRoom the room to forward the event from + * @param eventId the id of the event to forward + * @param toRoomIds the ids of the rooms to forward the event to + * @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room + */ + suspend fun forward( + fromRoom: Room, + eventId: EventId, + toRoomIds: List<RoomId>, + timeoutMs: Long = 5000L + ) { + val content = fromRoom.getTimelineEventContentByEventId(eventId.value) + val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } + val targetRooms = targetSlidingSyncRooms.mapNotNull { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoom() } } + val failedForwardingTo = mutableSetOf<RoomId>() + targetRooms.parallelMap { room -> + room.use { targetRoom -> + val result = runCatching { + // Sending a message requires a registered timeline listener + targetRoom.addTimelineListener(NoOpTimelineListener) + withTimeout(timeoutMs.milliseconds) { + targetRoom.send(content, genTransactionId()) + } + } + // After sending, we remove the timeline + targetRoom.removeTimeline() + result + }.onFailure { + failedForwardingTo.add(RoomId(room.id())) + if (it is CancellationException) { + throw it + } + } + } + + if (failedForwardingTo.isNotEmpty()) { + throw ForwardEventException(toRoomIds.toList()) + } + } + + private object NoOpTimelineListener : TimelineListener { + override fun onUpdate(diff: TimelineDiff) = Unit + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt new file mode 100644 index 0000000000..84c4eeaefb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import org.matrix.rustcomponents.sdk.RoomList +import org.matrix.rustcomponents.sdk.RoomListEntriesListener +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListEntry +import org.matrix.rustcomponents.sdk.RoomListException +import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.RoomListServiceState +import org.matrix.rustcomponents.sdk.RoomListServiceStateListener +import timber.log.Timber + +fun RoomList.loadingStateFlow(): Flow<RoomListLoadingState> = + mxCallbackFlow { + val listener = object : RoomListLoadingStateListener { + override fun onUpdate(state: RoomListLoadingState) { + trySendBlocking(state) + } + } + val result = loadingState(listener) + send(result.state) + result.stateStream + }.buffer(Channel.UNLIMITED) + +fun RoomList.entriesFlow(onInitialList: suspend (List<RoomListEntry>) -> Unit): Flow<RoomListEntriesUpdate> = + mxCallbackFlow { + val listener = object : RoomListEntriesListener { + override fun onUpdate(roomEntriesUpdate: RoomListEntriesUpdate) { + trySendBlocking(roomEntriesUpdate) + } + } + val result = entries(listener) + onInitialList(result.entries) + result.entriesStream + }.buffer(Channel.UNLIMITED) + +fun RoomListService.roomOrNull(roomId: String): RoomListItem? { + return try { + room(roomId) + } catch (exception: RoomListException) { + Timber.d(exception, "Failed finding room with id=$roomId.") + return null + } +} + +fun RoomListService.stateFlow(): Flow<RoomListServiceState> = + mxCallbackFlow { + val listener = object : RoomListServiceStateListener { + override fun onUpdate(state: RoomListServiceState) { + trySendBlocking(state) + } + } + state(listener) + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt new file mode 100644 index 0000000000..e79a8088aa --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState +import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember + +object RoomMemberMapper { + + fun map(roomMember: RustRoomMember): RoomMember = roomMember.use { + RoomMember( + UserId(it.userId()), + it.displayName(), + it.avatarUrl(), + mapMembership(it.membership()), + it.isNameAmbiguous(), + it.powerLevel(), + it.normalizedPowerLevel(), + it.isIgnored(), + ) + } + + fun mapMembership(membershipState: RustMembershipState): RoomMembershipState = + when (membershipState) { + RustMembershipState.BAN -> RoomMembershipState.BAN + RustMembershipState.INVITE -> RoomMembershipState.INVITE + RustMembershipState.JOIN -> RoomMembershipState.JOIN + RustMembershipState.KNOCK -> RoomMembershipState.KNOCK + RustMembershipState.LEAVE -> RoomMembershipState.LEAVE + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt new file mode 100644 index 0000000000..7dd7bf4581 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListItem + +class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) { + + suspend fun create(roomListItem: RoomListItem, room: Room?): RoomSummaryDetails { + val latestRoomMessage = roomListItem.latestEvent()?.use { + roomMessageFactory.create(it) + } + return RoomSummaryDetails( + roomId = RoomId(roomListItem.id()), + name = roomListItem.name() ?: roomListItem.id(), + canonicalAlias = roomListItem.canonicalAlias(), + isDirect = roomListItem.isDirect(), + avatarURLString = roomListItem.avatarUrl(), + unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() }, + lastMessage = latestRoomMessage, + lastMessageTimestamp = latestRoomMessage?.originServerTs, + inviter = room?.inviter()?.let(RoomMemberMapper::map), + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt new file mode 100644 index 0000000000..a8ab4cb807 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.matrix.api.room.RoomSummary +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListEntry +import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.RoomListService +import timber.log.Timber +import java.util.UUID + +class RoomSummaryListProcessor( + private val roomSummaries: MutableStateFlow<List<RoomSummary>>, + private val roomListService: RoomListService, + private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), + private val shouldFetchFullRoom: Boolean = false, +) { + + private val roomSummariesByIdentifier = HashMap<String, RoomSummary>() + private val initLatch = CompletableDeferred<Unit>() + private val mutex = Mutex() + + suspend fun postEntries(entries: List<RoomListEntry>) { + updateRoomSummaries { + Timber.v("Update rooms from postEntries (with ${entries.size} items) on ${Thread.currentThread()}") + val roomSummaries = entries.parallelMap(::buildSummaryForRoomListEntry) + addAll(roomSummaries) + } + initLatch.complete(Unit) + } + + suspend fun postUpdate(update: RoomListEntriesUpdate) { + // Makes sure to process first entries before update. + initLatch.await() + updateRoomSummaries { + Timber.v("Update rooms from postUpdate ($update) on ${Thread.currentThread()}") + applyUpdate(update) + } + } + + private suspend fun MutableList<RoomSummary>.applyUpdate(update: RoomListEntriesUpdate) { + when (update) { + is RoomListEntriesUpdate.Append -> { + val roomSummaries = update.values.map { + buildSummaryForRoomListEntry(it) + } + addAll(roomSummaries) + } + is RoomListEntriesUpdate.PushBack -> { + val roomSummary = buildSummaryForRoomListEntry(update.value) + add(roomSummary) + } + is RoomListEntriesUpdate.PushFront -> { + val roomSummary = buildSummaryForRoomListEntry(update.value) + add(0, roomSummary) + } + is RoomListEntriesUpdate.Set -> { + val roomSummary = buildSummaryForRoomListEntry(update.value) + this[update.index.toInt()] = roomSummary + } + is RoomListEntriesUpdate.Insert -> { + val roomSummary = buildSummaryForRoomListEntry(update.value) + add(update.index.toInt(), roomSummary) + } + is RoomListEntriesUpdate.Remove -> { + removeAt(update.index.toInt()) + } + is RoomListEntriesUpdate.Reset -> { + clear() + addAll(update.values.map { buildSummaryForRoomListEntry(it) }) + } + RoomListEntriesUpdate.PopBack -> { + removeLastOrNull() + } + RoomListEntriesUpdate.PopFront -> { + removeFirstOrNull() + } + RoomListEntriesUpdate.Clear -> { + clear() + } + } + } + + private suspend fun buildSummaryForRoomListEntry(entry: RoomListEntry): RoomSummary { + return when (entry) { + RoomListEntry.Empty -> buildEmptyRoomSummary() + is RoomListEntry.Filled -> buildAndCacheRoomSummaryForIdentifier(entry.roomId) + is RoomListEntry.Invalidated -> { + roomSummariesByIdentifier[entry.roomId] ?: buildEmptyRoomSummary() + } + } + } + + private fun buildEmptyRoomSummary(): RoomSummary { + return RoomSummary.Empty(UUID.randomUUID().toString()) + } + + private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary { + val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem -> + roomListItem.fullRoomOrNull().use { fullRoom -> + RoomSummary.Filled( + details = roomSummaryDetailsFactory.create(roomListItem, fullRoom) + ) + } + } ?: buildEmptyRoomSummary() + roomSummariesByIdentifier[builtRoomSummary.identifier()] = builtRoomSummary + return builtRoomSummary + } + + private fun RoomListItem.fullRoomOrNull(): Room? { + return if (shouldFetchFullRoom) { + fullRoom() + } else { + null + } + } + + private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = + mutex.withLock { + val mutableRoomSummaries = roomSummaries.value.toMutableList() + block(mutableRoomSummaries) + roomSummaries.value = mutableRoomSummaries + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt new file mode 100644 index 0000000000..16434aa3c8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -0,0 +1,408 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.matrix.impl.core.toProgressWatcher +import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.room.location.toInner +import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline +import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow +import io.element.android.libraries.matrix.impl.timeline.eventOrigin +import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.EventItemOrigin +import org.matrix.rustcomponents.sdk.RequiredState +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.RoomSubscription +import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle +import org.matrix.rustcomponents.sdk.genTransactionId +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown +import timber.log.Timber +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class RustMatrixRoom( + override val sessionId: SessionId, + private val roomListItem: RoomListItem, + private val innerRoom: Room, + sessionCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, + private val systemClock: SystemClock, + private val roomContentForwarder: RoomContentForwarder, + private val sessionData: SessionData, +) : MatrixRoom { + + override val roomId = RoomId(innerRoom.id()) + + // Create a dispatcher for all room methods... + private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) + + //...except getMember methods as it could quickly fill the roomDispatcher... + private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8) + + private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") + private val _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown) + private val isInit = MutableStateFlow(false) + private val _syncUpdateFlow = MutableStateFlow(0L) + private val _timeline by lazy { + RustMatrixTimeline( + matrixRoom = this, + innerRoom = innerRoom, + roomCoroutineScope = roomCoroutineScope, + dispatcher = roomDispatcher, + lastLoginTimestamp = sessionData.loginTimestamp, + ) + } + + override val membersStateFlow: StateFlow<MatrixRoomMembersState> = _membersStateFlow.asStateFlow() + + override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow() + + override val timeline: MatrixTimeline = _timeline + + override fun open(): Result<Unit> { + if (isInit.value) return Result.failure(IllegalStateException("Listener already registered")) + val settings = RoomSubscription( + requiredState = listOf( + RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""), + RequiredState(key = EventType.STATE_ROOM_TOPIC, value = ""), + RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""), + RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""), + ), + timelineLimit = null + ) + roomListItem.subscribe(settings) + roomCoroutineScope.launch(roomDispatcher) { + innerRoom.timelineDiffFlow { initialList -> + _timeline.postItems(initialList) + }.onEach { diff -> + if (diff.eventOrigin() == EventItemOrigin.SYNC) { + _syncUpdateFlow.value = systemClock.epochMillis() + } + _timeline.postDiff(diff) + }.launchIn(this) + + innerRoom.backPaginationStatusFlow() + .onEach { + _timeline.postPaginationStatus(it) + }.launchIn(this) + + fetchMembers() + } + isInit.value = true + return Result.success(Unit) + } + + override fun close() { + if (isInit.value) { + isInit.value = false + roomCoroutineScope.cancel() + roomListItem.unsubscribe() + innerRoom.destroy() + roomListItem.destroy() + } + } + + override val name: String? + get() { + return roomListItem.name() + } + + override val displayName: String + get() { + return innerRoom.displayName() + } + + override val topic: String? + get() { + return innerRoom.topic() + } + + override val avatarUrl: String? + get() { + return innerRoom.avatarUrl() + } + + override val isEncrypted: Boolean + get() = runCatching { innerRoom.isEncrypted() }.getOrDefault(false) + + override val alias: String? + get() = innerRoom.canonicalAlias() + + override val alternativeAliases: List<String> + get() = innerRoom.alternativeAliases() + + override val isPublic: Boolean + get() = innerRoom.isPublic() + + override val isDirect: Boolean + get() = innerRoom.isDirect() + + override val joinedMemberCount: Long + get() = innerRoom.joinedMembersCount().toLong() + + override val activeMemberCount: Long + get() = innerRoom.activeMembersCount().toLong() + + override suspend fun updateMembers(): Result<Unit> = withContext(roomMembersDispatcher) { + val currentState = _membersStateFlow.value + val currentMembers = currentState.roomMembers() + _membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers) + runCatching { + innerRoom.members().map(RoomMemberMapper::map) + }.map { + _membersStateFlow.value = MatrixRoomMembersState.Ready(it) + }.onFailure { + _membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = it) + } + } + + override suspend fun userDisplayName(userId: UserId): Result<String?> = withContext(roomDispatcher) { + runCatching { + innerRoom.memberDisplayName(userId.value) + } + } + + override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) { + runCatching { + innerRoom.memberAvatarUrl(userId.value) + } + } + + override suspend fun sendMessage(message: String): Result<Unit> = withContext(roomDispatcher) { + val transactionId = genTransactionId() + messageEventContentFromMarkdown(message).use { content -> + runCatching { + innerRoom.send(content, transactionId) + } + } + } + + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) { + if (originalEventId != null) { + runCatching { + innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId?.value) + } + } else { + runCatching { + transactionId?.let { cancelSend(it) } + innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId()) + } + } + } + + override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) { + val transactionId = genTransactionId() + // val content = messageEventContentFromMarkdown(message) + runCatching { + innerRoom.sendReply(/* TODO use content */ message, eventId.value, transactionId) + } + } + + override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) { + val transactionId = genTransactionId() + runCatching { + innerRoom.redact(eventId.value, reason, transactionId) + } + } + + override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.leave() + } + } + + override suspend fun join(): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.join() + } + } + + override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.inviteUserById(id.value) + } + } + + override suspend fun canUserInvite(userId: UserId): Result<Boolean> { + return runCatching { + innerRoom.canUserInvite(userId.value) + } + } + + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> { + return runCatching { + innerRoom.canUserSendState(userId.value, type.map()) + } + } + + override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> { + return runCatching { + innerRoom.canUserSendMessage(userId.value, type.map()) + } + } + + override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result<Unit> { + return sendAttachment { + innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher()) + } + } + + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<Unit> { + return sendAttachment { + innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher()) + } + } + + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<Unit> { + return sendAttachment { + innerRoom.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher()) + } + } + + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> { + return sendAttachment { + innerRoom.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher()) + } + } + + override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.toggleReaction(key = emoji, eventId = eventId.value) + } + } + + override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) { + runCatching { + roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds) + }.onFailure { + Timber.e(it) + } + } + + override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.retrySend(transactionId.value) + } + } + + override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.cancelSend(transactionId.value) + } + } + + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList()) + } + } + + override suspend fun removeAvatar(): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.removeAvatar() + } + } + + override suspend fun setName(name: String): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.setName(name) + } + } + + override suspend fun setTopic(topic: String): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.setTopic(topic) + } + } + + private suspend fun fetchMembers() = withContext(roomDispatcher) { + runCatching { + innerRoom.fetchMembers() + } + } + + override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason) + if (blockUserId != null) { + innerRoom.ignoreUser(blockUserId.value) + } + } + } + + override suspend fun sendLocation( + body: String, + geoUri: String, + description: String?, + zoomLevel: Int?, + assetType: AssetType?, + ): Result<Unit> = withContext(roomDispatcher) { + runCatching { + innerRoom.sendLocation( + body = body, + geoUri = geoUri, + description = description, + zoomLevel = zoomLevel?.toUByte(), + assetType = assetType?.toInner(), + txnId = genTransactionId() + ) + } + } +} + +//TODO handle cancellation, need refactoring of how we are catching errors +private suspend fun sendAttachment(handle: () -> SendAttachmentJoinHandle): Result<Unit> { + return runCatching { + handle().use { + it.join() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt new file mode 100644 index 0000000000..efdbcf34ad --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.RoomList +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListException +import org.matrix.rustcomponents.sdk.RoomListInput +import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListRange +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.RoomListServiceState +import timber.log.Timber + +internal class RustRoomSummaryDataSource( + private val roomListService: RoomListService, + private val sessionCoroutineScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), +) : RoomSummaryDataSource { + + private val allRooms = MutableStateFlow<List<RoomSummary>>(emptyList()) + private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList()) + + private val allRoomsLoadingState: MutableStateFlow<RoomSummaryDataSource.LoadingState> = MutableStateFlow(RoomSummaryDataSource.LoadingState.NotLoaded) + private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false) + private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true) + + init { + sessionCoroutineScope.launch(dispatcher) { + val allRooms = roomListService.allRooms() + allRooms + .observeEntriesWithProcessor(allRoomsListProcessor) + .launchIn(this) + + allRooms + .loadingStateFlow() + .map { it.toRoomSummaryDataSourceLoadingState() } + .onEach { + allRoomsLoadingState.value = it + }.launchIn(this) + + launch { + // Wait until running, as invites is only available after that + roomListService.stateFlow().first { + it == RoomListServiceState.RUNNING + } + roomListService.invites() + .observeEntriesWithProcessor(inviteRoomsListProcessor) + .launchIn(this) + } + } + } + + override fun allRooms(): StateFlow<List<RoomSummary>> { + return allRooms + } + + override fun inviteRooms(): StateFlow<List<RoomSummary>> { + return inviteRooms + } + + override fun allRoomsLoadingState(): StateFlow<RoomSummaryDataSource.LoadingState> { + return allRoomsLoadingState + } + + override fun updateAllRoomsVisibleRange(range: IntRange) { + Timber.v("setVisibleRange=$range") + sessionCoroutineScope.launch { + try { + val ranges = listOf(RoomListRange(range.first.toUInt(), range.last.toUInt())) + roomListService.applyInput( + RoomListInput.Viewport(ranges) + ) + } catch (exception: RoomListException) { + Timber.e(exception, "Failed updating visible range") + } + } + } +} + +private fun RoomListLoadingState.toRoomSummaryDataSourceLoadingState(): RoomSummaryDataSource.LoadingState { + return when (this) { + is RoomListLoadingState.Loaded -> RoomSummaryDataSource.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) + is RoomListLoadingState.NotLoaded -> RoomSummaryDataSource.LoadingState.NotLoaded + } +} + +private fun RoomList.observeEntriesWithProcessor(processor: RoomSummaryListProcessor): Flow<RoomListEntriesUpdate> { + return entriesFlow { roomListEntries -> + processor.postEntries(roomListEntries) + }.onEach { update -> + processor.postUpdate(update) + } +} + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt new file mode 100644 index 0000000000..2cd09e213c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.StateEventType +import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType + +fun StateEventType.map(): RustStateEventType = when (this) { + StateEventType.POLICY_RULE_ROOM -> RustStateEventType.POLICY_RULE_ROOM + StateEventType.POLICY_RULE_SERVER -> RustStateEventType.POLICY_RULE_SERVER + StateEventType.POLICY_RULE_USER -> RustStateEventType.POLICY_RULE_USER + StateEventType.ROOM_ALIASES -> RustStateEventType.ROOM_ALIASES + StateEventType.ROOM_AVATAR -> RustStateEventType.ROOM_AVATAR + StateEventType.ROOM_CANONICAL_ALIAS -> RustStateEventType.ROOM_CANONICAL_ALIAS + StateEventType.ROOM_CREATE -> RustStateEventType.ROOM_CREATE + StateEventType.ROOM_ENCRYPTION -> RustStateEventType.ROOM_ENCRYPTION + StateEventType.ROOM_GUEST_ACCESS -> RustStateEventType.ROOM_GUEST_ACCESS + StateEventType.ROOM_HISTORY_VISIBILITY -> RustStateEventType.ROOM_HISTORY_VISIBILITY + StateEventType.ROOM_JOIN_RULES -> RustStateEventType.ROOM_JOIN_RULES + StateEventType.ROOM_MEMBER_EVENT -> RustStateEventType.ROOM_MEMBER_EVENT + StateEventType.ROOM_NAME -> RustStateEventType.ROOM_NAME + StateEventType.ROOM_PINNED_EVENTS -> RustStateEventType.ROOM_PINNED_EVENTS + StateEventType.ROOM_POWER_LEVELS -> RustStateEventType.ROOM_POWER_LEVELS + StateEventType.ROOM_SERVER_ACL -> RustStateEventType.ROOM_SERVER_ACL + StateEventType.ROOM_THIRD_PARTY_INVITE -> RustStateEventType.ROOM_THIRD_PARTY_INVITE + StateEventType.ROOM_TOMBSTONE -> RustStateEventType.ROOM_TOMBSTONE + StateEventType.ROOM_TOPIC -> RustStateEventType.ROOM_TOPIC + StateEventType.SPACE_CHILD -> RustStateEventType.SPACE_CHILD + StateEventType.SPACE_PARENT -> RustStateEventType.SPACE_PARENT +} + +fun RustStateEventType.map(): StateEventType = when (this) { + RustStateEventType.POLICY_RULE_ROOM -> StateEventType.POLICY_RULE_ROOM + RustStateEventType.POLICY_RULE_SERVER -> StateEventType.POLICY_RULE_SERVER + RustStateEventType.POLICY_RULE_USER -> StateEventType.POLICY_RULE_USER + RustStateEventType.ROOM_ALIASES -> StateEventType.ROOM_ALIASES + RustStateEventType.ROOM_AVATAR -> StateEventType.ROOM_AVATAR + RustStateEventType.ROOM_CANONICAL_ALIAS -> StateEventType.ROOM_CANONICAL_ALIAS + RustStateEventType.ROOM_CREATE -> StateEventType.ROOM_CREATE + RustStateEventType.ROOM_ENCRYPTION -> StateEventType.ROOM_ENCRYPTION + RustStateEventType.ROOM_GUEST_ACCESS -> StateEventType.ROOM_GUEST_ACCESS + RustStateEventType.ROOM_HISTORY_VISIBILITY -> StateEventType.ROOM_HISTORY_VISIBILITY + RustStateEventType.ROOM_JOIN_RULES -> StateEventType.ROOM_JOIN_RULES + RustStateEventType.ROOM_MEMBER_EVENT -> StateEventType.ROOM_MEMBER_EVENT + RustStateEventType.ROOM_NAME -> StateEventType.ROOM_NAME + RustStateEventType.ROOM_PINNED_EVENTS -> StateEventType.ROOM_PINNED_EVENTS + RustStateEventType.ROOM_POWER_LEVELS -> StateEventType.ROOM_POWER_LEVELS + RustStateEventType.ROOM_SERVER_ACL -> StateEventType.ROOM_SERVER_ACL + RustStateEventType.ROOM_THIRD_PARTY_INVITE -> StateEventType.ROOM_THIRD_PARTY_INVITE + RustStateEventType.ROOM_TOMBSTONE -> StateEventType.ROOM_TOMBSTONE + RustStateEventType.ROOM_TOPIC -> StateEventType.ROOM_TOPIC + RustStateEventType.SPACE_CHILD -> StateEventType.SPACE_CHILD + RustStateEventType.SPACE_PARENT -> StateEventType.SPACE_PARENT +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt new file mode 100644 index 0000000000..e886dae442 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.room.location.AssetType + +fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) { + AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER + AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt new file mode 100644 index 0000000000..3c298b5ec6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/message/RoomMessageFactory.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.room.message + +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper +import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem + +class RoomMessageFactory { + fun create(eventTimelineItem: RustEventTimelineItem?): RoomMessage? { + eventTimelineItem ?: return null + val mappedTimelineItem = EventTimelineItemMapper().map(eventTimelineItem) + return RoomMessage( + eventId = mappedTimelineItem.eventId!!, + event = mappedTimelineItem, + sender = mappedTimelineItem.sender, + originServerTs = mappedTimelineItem.timestamp, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt new file mode 100644 index 0000000000..51228231f9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.matrix.api.sync.SyncState +import org.matrix.rustcomponents.sdk.RoomListServiceState +import org.matrix.rustcomponents.sdk.SyncServiceState + +internal fun RoomListServiceState.toSyncState(): SyncState { + return when (this) { + RoomListServiceState.INIT, + RoomListServiceState.SETTING_UP -> SyncState.Idle + RoomListServiceState.RUNNING -> SyncState.Running + RoomListServiceState.ERROR -> SyncState.Error + RoomListServiceState.TERMINATED -> SyncState.Terminated + } +} + +internal fun SyncServiceState.toSyncState(): SyncState { + return when (this) { + SyncServiceState.IDLE -> SyncState.Idle + SyncServiceState.RUNNING -> SyncState.Running + SyncServiceState.TERMINATED -> SyncState.Terminated + SyncServiceState.ERROR -> SyncState.Error + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt new file mode 100644 index 0000000000..ca40ca400c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import org.matrix.rustcomponents.sdk.RoomListServiceState +import org.matrix.rustcomponents.sdk.SyncServiceInterface +import timber.log.Timber + +class RustSyncService( + private val innerSyncService: SyncServiceInterface, + roomListStateFlow: Flow<RoomListServiceState>, + sessionCoroutineScope: CoroutineScope +) : SyncService { + + override suspend fun startSync() = runCatching { + Timber.v("Start sync") + innerSyncService.start() + } + + override fun stopSync() = runCatching { + Timber.v("Stop sync") + innerSyncService.pause() + } + + override val syncState: StateFlow<SyncState> = + roomListStateFlow + .map(RoomListServiceState::toSyncState) + .onEach { state -> + Timber.v("Sync state=$state") + } + .distinctUntilChanged() + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt new file mode 100644 index 0000000000..36dabb71f3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.SyncServiceState +import org.matrix.rustcomponents.sdk.SyncServiceStateObserver + +fun SyncService.stateFlow(): Flow<SyncServiceState> = + mxCallbackFlow { + val listener = object : SyncServiceStateObserver { + override fun onUpdate(state: SyncServiceState) { + trySendBlocking(state) + } + } + state(listener) + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt new file mode 100644 index 0000000000..b14d459697 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.TimelineChange +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItem + +internal class MatrixTimelineDiffProcessor( + private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>>, + private val timelineItemFactory: MatrixTimelineItemMapper, +) { + + private val mutex = Mutex() + + suspend fun postItems(items: List<TimelineItem>) { + updateTimelineItems { + val mappedItems = items.map { it.asMatrixTimelineItem() } + addAll(0, mappedItems) + } + } + + suspend fun postDiff(diff: TimelineDiff) { + updateTimelineItems { + applyDiff(diff) + } + } + + private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) = + mutex.withLock { + val mutableTimelineItems = timelineItems.value.toMutableList() + block(mutableTimelineItems) + timelineItems.value = mutableTimelineItems + } + + private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) { + when (diff.change()) { + TimelineChange.APPEND -> { + val items = diff.append()?.map { it.asMatrixTimelineItem() } ?: return + addAll(items) + } + TimelineChange.PUSH_BACK -> { + val item = diff.pushBack()?.asMatrixTimelineItem() ?: return + add(item) + } + TimelineChange.PUSH_FRONT -> { + val item = diff.pushFront()?.asMatrixTimelineItem() ?: return + add(0, item) + } + TimelineChange.SET -> { + val updateAtData = diff.set() ?: return + val item = updateAtData.item.asMatrixTimelineItem() + set(updateAtData.index.toInt(), item) + } + TimelineChange.INSERT -> { + val insertAtData = diff.insert() ?: return + val item = insertAtData.item.asMatrixTimelineItem() + add(insertAtData.index.toInt(), item) + } + TimelineChange.REMOVE -> { + val removeAtData = diff.remove() ?: return + removeAt(removeAtData.toInt()) + } + TimelineChange.RESET -> { + clear() + val items = diff.reset()?.map { it.asMatrixTimelineItem() } ?: return + addAll(items) + } + TimelineChange.POP_FRONT -> { + removeFirstOrNull() + } + TimelineChange.POP_BACK -> { + removeLastOrNull() + } + TimelineChange.CLEAR -> { + clear() + } + } + } + + private fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem { + return timelineItemFactory.map(this) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt new file mode 100644 index 0000000000..2e86aa0706 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper +import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.TimelineItem + +class MatrixTimelineItemMapper( + private val fetchDetailsForEvent: suspend (EventId) -> Result<Unit>, + private val roomCoroutineScope: CoroutineScope, + private val virtualTimelineItemMapper: VirtualTimelineItemMapper = VirtualTimelineItemMapper(), + private val eventTimelineItemMapper: EventTimelineItemMapper = EventTimelineItemMapper(), +) { + + fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use { + val uniqueId = timelineItem.uniqueId().toLong() + val asEvent = it.asEvent() + if (asEvent != null) { + val eventTimelineItem = eventTimelineItemMapper.map(asEvent) + if (eventTimelineItem.hasNotLoadedInReplyTo() && eventTimelineItem.eventId != null) { + fetchEventDetails(eventTimelineItem.eventId!!) + } + + return MatrixTimelineItem.Event(uniqueId, eventTimelineItem) + } + val asVirtual = it.asVirtual() + if (asVirtual != null) { + val virtualTimelineItem = virtualTimelineItemMapper.map(asVirtual) + return MatrixTimelineItem.Virtual(uniqueId, virtualTimelineItem) + } + return MatrixTimelineItem.Other + } + + private fun fetchEventDetails(eventId: EventId) = roomCoroutineScope.launch { + fetchDetailsForEvent(eventId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt new file mode 100644 index 0000000000..d6febb32dc --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import org.matrix.rustcomponents.sdk.BackPaginationStatus +import org.matrix.rustcomponents.sdk.BackPaginationStatusListener +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItem +import org.matrix.rustcomponents.sdk.TimelineListener + +internal fun Room.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<TimelineDiff> = + mxCallbackFlow { + val listener = object : TimelineListener { + override fun onUpdate(diff: TimelineDiff) { + trySendBlocking(diff) + } + } + val result = addTimelineListener(listener) + onInitialList(result.items) + result.itemsStream + }.buffer(Channel.UNLIMITED) + +internal fun Room.backPaginationStatusFlow(): Flow<BackPaginationStatus> = + mxCallbackFlow { + val listener = object : BackPaginationStatusListener { + override fun onUpdate(status: BackPaginationStatus) { + trySendBlocking(status) + } + } + subscribeToBackPaginationStatus(listener) + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt new file mode 100644 index 0000000000..e213fb623c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper +import kotlinx.coroutines.CompletableDeferred +import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.BackPaginationStatus +import org.matrix.rustcomponents.sdk.PaginationOptions +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import java.util.Date + +private const val INITIAL_MAX_SIZE = 50 + +class RustMatrixTimeline( + roomCoroutineScope: CoroutineScope, + private val matrixRoom: MatrixRoom, + private val innerRoom: Room, + private val dispatcher: CoroutineDispatcher, + private val lastLoginTimestamp: Date?, +) : MatrixTimeline { + + private val initLatch = CompletableDeferred<Unit>() + private val isInit = AtomicBoolean(false) + + private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = + MutableStateFlow(emptyList()) + + private val _paginationState = MutableStateFlow( + MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false) + ) + + private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor( + lastLoginTimestamp = lastLoginTimestamp, + isRoomEncrypted = matrixRoom.isEncrypted, + paginationStateFlow = _paginationState, + ) + + private val timelineItemFactory = MatrixTimelineItemMapper( + fetchDetailsForEvent = this::fetchDetailsForEvent, + roomCoroutineScope = roomCoroutineScope, + virtualTimelineItemMapper = VirtualTimelineItemMapper(), + eventTimelineItemMapper = EventTimelineItemMapper( + contentMapper = TimelineEventContentMapper( + eventMessageMapper = EventMessageMapper() + ) + ) + ) + + private val timelineDiffProcessor = MatrixTimelineDiffProcessor( + timelineItems = _timelineItems, + timelineItemFactory = timelineItemFactory, + ) + + override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow() + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.sample(50) + .mapLatest { items -> + encryptedHistoryPostProcessor.process(items) + } + + internal suspend fun postItems(items: List<TimelineItem>) { + // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap. + items.chunked(INITIAL_MAX_SIZE).reversed().forEach { + timelineDiffProcessor.postItems(it) + } + isInit.set(true) + initLatch.complete(Unit) + } + + internal suspend fun postDiff(timelineDiff: TimelineDiff) { + initLatch.await() + timelineDiffProcessor.postDiff(timelineDiff) + } + + internal fun postPaginationStatus(status: BackPaginationStatus) { + _paginationState.getAndUpdate { currentPaginationState -> + if (hasEncryptionHistoryBanner()) { + return@getAndUpdate currentPaginationState.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = false, + ) + } + when (status) { + BackPaginationStatus.IDLE -> { + currentPaginationState.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = true + ) + } + BackPaginationStatus.PAGINATING -> { + currentPaginationState.copy( + isBackPaginating = true, + hasMoreToLoadBackwards = true + ) + } + BackPaginationStatus.TIMELINE_START_REACHED -> { + currentPaginationState.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = false + ) + } + } + } + } + + override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) { + runCatching { + innerRoom.fetchDetailsForEvent(eventId.value) + } + } + + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(dispatcher) { + runCatching { + if (!canBackPaginate()) throw TimelineException.CannotPaginate + Timber.v("Start back paginating for room ${matrixRoom.roomId} ") + val paginationOptions = PaginationOptions.UntilNumItems( + eventLimit = requestSize.toUShort(), + items = untilNumberOfItems.toUShort(), + waitForToken = true, + ) + innerRoom.paginateBackwards(paginationOptions) + }.onFailure { + Timber.d(it, "Fail to paginate for room ${matrixRoom.roomId}") + }.onSuccess { + Timber.v("Success back paginating for room ${matrixRoom.roomId}") + } + } + + private fun canBackPaginate(): Boolean { + return isInit.get() && paginationState.value.canBackPaginate + } + + override suspend fun sendReadReceipt(eventId: EventId) = withContext(dispatcher) { + runCatching { + innerRoom.sendReadReceipt(eventId = eventId.value) + } + } + + fun getItemById(eventId: EventId): MatrixTimelineItem.Event? { + return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event + } + + private fun hasEncryptionHistoryBanner(): Boolean { + val firstItem = _timelineItems.value.firstOrNull() + return firstItem is MatrixTimelineItem.Virtual + && firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt new file mode 100644 index 0000000000..59165463bb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline + +import org.matrix.rustcomponents.sdk.EventItemOrigin +import org.matrix.rustcomponents.sdk.TimelineChange +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItem + +/** + * Tries to get an event origin from the TimelineDiff. + * If there is multiple events in the diff, uses the first one as it should be a good indicator. + */ +internal fun TimelineDiff.eventOrigin(): EventItemOrigin? { + return when (change()) { + TimelineChange.APPEND -> { + append()?.firstOrNull()?.eventOrigin() + } + TimelineChange.PUSH_BACK -> { + pushBack()?.eventOrigin() + } + TimelineChange.PUSH_FRONT -> { + pushFront()?.eventOrigin() + } + TimelineChange.SET -> { + set()?.item?.eventOrigin() + } + TimelineChange.INSERT -> { + insert()?.item?.eventOrigin() + } + TimelineChange.RESET -> { + reset()?.firstOrNull()?.eventOrigin() + } + else -> null + } +} + +private fun TimelineItem.eventOrigin(): EventItemOrigin? { + return asEvent()?.origin() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt new file mode 100644 index 0000000000..9f4df1f6b3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.impl.media.map +import org.matrix.rustcomponents.sdk.Message +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.RepliedToEventDetails +import org.matrix.rustcomponents.sdk.use +import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat +import org.matrix.rustcomponents.sdk.MessageType as RustMessageType + +class EventMessageMapper { + + fun map(message: Message): MessageContent = message.use { + val type = it.msgtype().use(this::mapMessageType) + val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) + val inReplyToEvent: InReplyTo? = (it.inReplyTo()?.event)?.use { details -> + when (details) { + is RepliedToEventDetails.Ready -> { + val senderProfile = details.senderProfile as? ProfileDetails.Ready + InReplyTo.Ready( + eventId = inReplyToId!!, + content = map(details.message), + senderId = UserId(details.sender), + senderDisplayName = senderProfile?.displayName, + senderAvatarUrl = senderProfile?.avatarUrl, + ) + } + is RepliedToEventDetails.Error -> InReplyTo.Error + is RepliedToEventDetails.Pending -> InReplyTo.Pending + is RepliedToEventDetails.Unavailable -> InReplyTo.NotLoaded(inReplyToId!!) + } + } + MessageContent( + body = it.body(), + inReplyTo = inReplyToEvent, + isEdited = it.isEdited(), + type = type + ) + } + + fun mapMessageType(type: RustMessageType?) = when (type) { + is RustMessageType.Audio -> { + AudioMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) + } + is RustMessageType.File -> { + FileMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) + } + is RustMessageType.Image -> { + ImageMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) + } + is RustMessageType.Notice -> { + NoticeMessageType(type.content.body, type.content.formatted?.map()) + } + is RustMessageType.Text -> { + TextMessageType(type.content.body, type.content.formatted?.map()) + } + is RustMessageType.Emote -> { + EmoteMessageType(type.content.body, type.content.formatted?.map()) + } + is RustMessageType.Video -> { + VideoMessageType(type.content.body, type.content.source.map(), type.content.info?.map()) + } + is RustMessageType.Location -> { + LocationMessageType(type.content.body, type.content.geoUri, type.content.description) + } + null -> UnknownMessageType + } +} + +private fun RustFormattedBody.map(): FormattedBody = FormattedBody( + format = format.map(), + body = body +) + +private fun RustMessageFormat.map(): MessageFormat { + return when (this) { + RustMessageFormat.Html -> MessageFormat.HTML + is RustMessageFormat.Unknown -> MessageFormat.UNKNOWN + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt new file mode 100644 index 0000000000..359b9ecdef --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import org.matrix.rustcomponents.sdk.Reaction +import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin +import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState +import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem +import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo +import org.matrix.rustcomponents.sdk.ProfileDetails as RustProfileDetails + +class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper()) { + + fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use { + EventTimelineItem( + eventId = it.eventId()?.let(::EventId), + transactionId = it.transactionId()?.let(::TransactionId), + isEditable = it.isEditable(), + isLocal = it.isLocal(), + isOwn = it.isOwn(), + isRemote = it.isRemote(), + localSendState = it.localSendState()?.map(), + reactions = it.reactions().map(), + sender = UserId(it.sender()), + senderProfile = it.senderProfile().map(), + timestamp = it.timestamp().toLong(), + content = contentMapper.map(it.content()), + debugInfo = it.debugInfo().map(), + origin = it.origin()?.map() + ) + } +} + +fun RustProfileDetails.map(): ProfileTimelineDetails { + return when (this) { + RustProfileDetails.Pending -> ProfileTimelineDetails.Pending + RustProfileDetails.Unavailable -> ProfileTimelineDetails.Unavailable + is RustProfileDetails.Error -> ProfileTimelineDetails.Error(message) + is RustProfileDetails.Ready -> ProfileTimelineDetails.Ready( + displayName = displayName, + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = avatarUrl + ) + } +} + +fun RustEventSendState?.map(): LocalEventSendState? { + return when (this) { + null -> null + RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet + is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error) + is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) + RustEventSendState.Cancelled -> LocalEventSendState.Canceled + } +} + +private fun List<Reaction>?.map(): List<EventReaction> { + return this?.map { + EventReaction( + key = it.key, + count = it.count.toLong(), + senderIds = it.senders.map { sender -> UserId(sender.senderId) } + ) + } ?: emptyList() +} + +private fun RustEventTimelineItemDebugInfo.map(): TimelineItemDebugInfo { + return TimelineItemDebugInfo( + model = model, + originalJson = originalJson, + latestEditedJson = latestEditJson, + ) +} + +private fun RustEventItemOrigin.map(): TimelineItemEventOrigin { + return when (this) { + RustEventItemOrigin.LOCAL -> TimelineItemEventOrigin.LOCAL + RustEventItemOrigin.SYNC -> TimelineItemEventOrigin.SYNC + RustEventItemOrigin.PAGINATION -> TimelineItemEventOrigin.PAGINATION + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt new file mode 100644 index 0000000000..33727c15d4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.impl.media.map +import org.matrix.rustcomponents.sdk.TimelineItemContent +import org.matrix.rustcomponents.sdk.TimelineItemContentKind +import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage +import org.matrix.rustcomponents.sdk.MembershipChange as RustMembershipChange +import org.matrix.rustcomponents.sdk.OtherState as RustOtherState + +class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) { + + fun map(content: TimelineItemContent): EventContent = content.use { + when (val kind = it.kind()) { + is TimelineItemContentKind.FailedToParseMessageLike -> { + FailedToParseMessageLikeContent( + eventType = kind.eventType, + error = kind.error + ) + } + is TimelineItemContentKind.FailedToParseState -> { + FailedToParseStateContent( + eventType = kind.eventType, + stateKey = kind.stateKey, + error = kind.error + ) + } + TimelineItemContentKind.Message -> { + val message = it.asMessage() + if (message == null) { + UnknownContent + } else { + eventMessageMapper.map(message) + } + } + is TimelineItemContentKind.ProfileChange -> { + ProfileChangeContent( + displayName = kind.displayName, + prevDisplayName = kind.prevDisplayName, + avatarUrl = kind.avatarUrl, + prevAvatarUrl = kind.prevAvatarUrl + ) + } + TimelineItemContentKind.RedactedMessage -> { + RedactedContent + } + is TimelineItemContentKind.RoomMembership -> { + RoomMembershipContent( + UserId(kind.userId), + kind.change?.map() + ) + } + is TimelineItemContentKind.State -> { + StateContent( + stateKey = kind.stateKey, + content = kind.content.map() + ) + } + is TimelineItemContentKind.Sticker -> { + StickerContent( + body = kind.body, + info = kind.info.map(), + url = kind.url, + ) + } + is TimelineItemContentKind.UnableToDecrypt -> { + UnableToDecryptContent( + data = kind.msg.map() + ) + } + } + } +} + +private fun RustMembershipChange.map(): MembershipChange { + return when (this) { + RustMembershipChange.NONE -> MembershipChange.NONE + RustMembershipChange.ERROR -> MembershipChange.ERROR + RustMembershipChange.JOINED -> MembershipChange.JOINED + RustMembershipChange.LEFT -> MembershipChange.LEFT + RustMembershipChange.BANNED -> MembershipChange.BANNED + RustMembershipChange.UNBANNED -> MembershipChange.UNBANNED + RustMembershipChange.KICKED -> MembershipChange.KICKED + RustMembershipChange.INVITED -> MembershipChange.INVITED + RustMembershipChange.KICKED_AND_BANNED -> MembershipChange.KICKED_AND_BANNED + RustMembershipChange.INVITATION_ACCEPTED -> MembershipChange.INVITATION_ACCEPTED + RustMembershipChange.INVITATION_REJECTED -> MembershipChange.INVITATION_REJECTED + RustMembershipChange.INVITATION_REVOKED -> MembershipChange.INVITATION_REVOKED + RustMembershipChange.KNOCKED -> MembershipChange.KNOCKED + RustMembershipChange.KNOCK_ACCEPTED -> MembershipChange.KNOCK_ACCEPTED + RustMembershipChange.KNOCK_RETRACTED -> MembershipChange.KNOCK_RETRACTED + RustMembershipChange.KNOCK_DENIED -> MembershipChange.KNOCK_DENIED + RustMembershipChange.NOT_IMPLEMENTED -> MembershipChange.NOT_IMPLEMENTED + } +} + +//TODO extract state events? +private fun RustOtherState.map(): OtherState { + return when (this) { + is RustOtherState.Custom -> OtherState.Custom(eventType) + RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom + RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer + RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser + RustOtherState.RoomAliases -> OtherState.RoomAliases + is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url) + RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias + RustOtherState.RoomCreate -> OtherState.RoomCreate + RustOtherState.RoomEncryption -> OtherState.RoomEncryption + RustOtherState.RoomGuestAccess -> OtherState.RoomGuestAccess + RustOtherState.RoomHistoryVisibility -> OtherState.RoomHistoryVisibility + RustOtherState.RoomJoinRules -> OtherState.RoomJoinRules + is RustOtherState.RoomName -> OtherState.RoomName(name) + RustOtherState.RoomPinnedEvents -> OtherState.RoomPinnedEvents + RustOtherState.RoomPowerLevels -> OtherState.RoomPowerLevels + RustOtherState.RoomServerAcl -> OtherState.RoomServerAcl + is RustOtherState.RoomThirdPartyInvite -> OtherState.RoomThirdPartyInvite(displayName) + RustOtherState.RoomTombstone -> OtherState.RoomTombstone + is RustOtherState.RoomTopic -> OtherState.RoomTopic(topic) + RustOtherState.SpaceChild -> OtherState.SpaceChild + RustOtherState.SpaceParent -> OtherState.SpaceParent + } +} + +private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data { + return when (this) { + is RustEncryptedMessage.MegolmV1AesSha2 -> UnableToDecryptContent.Data.MegolmV1AesSha2(sessionId) + is RustEncryptedMessage.OlmV1Curve25519AesSha2 -> UnableToDecryptContent.Data.OlmV1Curve25519AesSha2(senderKey) + RustEncryptedMessage.Unknown -> UnableToDecryptContent.Data.Unknown + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt new file mode 100644 index 0000000000..c2b6a8c863 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.item.virtual + +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import org.matrix.rustcomponents.sdk.VirtualTimelineItem as RustVirtualTimelineItem + +class VirtualTimelineItemMapper { + + fun map(virtualTimelineItem: RustVirtualTimelineItem): VirtualTimelineItem { + return when (virtualTimelineItem) { + is RustVirtualTimelineItem.DayDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong()) + RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt new file mode 100644 index 0000000000..ca5c7342f8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import java.util.Date +import java.util.UUID + +class TimelineEncryptedHistoryPostProcessor( + private val lastLoginTimestamp: Date?, + private val isRoomEncrypted: Boolean, + private val paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState>, +) { + + fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> { + if (!isRoomEncrypted || lastLoginTimestamp == null) return items + + val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items) + // Disable back pagination + val wasFiltered = filteredItems !== items + if (wasFiltered) { + paginationStateFlow.getAndUpdate { + it.copy( + isBackPaginating = false, + hasMoreToLoadBackwards = false + ) + } + } + return filteredItems + } + + private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> { + var lastEncryptedHistoryBannerIndex = -1 + for ((i, item) in list.withIndex()) { + if (isItemEncryptionHistory(item)) { + lastEncryptedHistoryBannerIndex = i + } + } + return if (lastEncryptedHistoryBannerIndex >= 0) { + val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList() + sublist.add(0, MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)) + sublist + } else { + list + } + } + + private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean { + if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) { + return true + } + val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false + return timestamp <= lastLoginTimestamp!!.time + } + +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt new file mode 100644 index 0000000000..f32b18c9f2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.tracing + +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import timber.log.Timber + +fun setupTracing(tracingConfiguration: TracingConfiguration) { + val filter = tracingConfiguration.filter + Timber.v("Tracing config filter = $filter") + org.matrix.rustcomponents.sdk.setupTracing(filter) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt new file mode 100644 index 0000000000..5b6db0b224 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserProfileMapper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.usersearch + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import org.matrix.rustcomponents.sdk.UserProfile + +object UserProfileMapper { + fun map(userProfile: UserProfile): MatrixUser = + MatrixUser( + userId = UserId(userProfile.userId), + displayName = userProfile.displayName, + avatarUrl = userProfile.avatarUrl, + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt new file mode 100644 index 0000000000..1ec0b512ec --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.usersearch + +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import org.matrix.rustcomponents.sdk.SearchUsersResults + +object UserSearchResultMapper { + + fun map(result: SearchUsersResults): MatrixSearchUserResults { + return MatrixSearchUserResults( + results = result.results.map(UserProfileMapper::map), + limited = result.limited, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt new file mode 100644 index 0000000000..a347973e89 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.util + +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import org.matrix.rustcomponents.sdk.TaskHandle + +internal fun <T> mxCallbackFlow(block: suspend ProducerScope<T>.() -> TaskHandle) = + callbackFlow { + val token: TaskHandle = block(this) + awaitClose { + token.cancel() + token.destroy() + } + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt new file mode 100644 index 0000000000..4910445bee --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.util + +import org.matrix.rustcomponents.sdk.ClientException +import timber.log.Timber + +fun logError(throwable: Throwable) { + when (throwable) { + is ClientException.Generic -> { + Timber.e("Error ${throwable.msg}", throwable) + } + else -> { + Timber.e("Error", throwable) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt new file mode 100644 index 0000000000..9a21645351 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.util + +import org.matrix.rustcomponents.sdk.TaskHandle +import java.util.concurrent.CopyOnWriteArraySet + +class TaskHandleBag(private val tokens: MutableSet<TaskHandle> = CopyOnWriteArraySet()) : Set<TaskHandle> by tokens { + + operator fun plusAssign(taskHandle: TaskHandle?) { + if (taskHandle == null) return + tokens += taskHandle + } + + fun dispose() { + tokens.forEach { + it.cancel() + it.destroy() + } + tokens.clear() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt new file mode 100644 index 0000000000..dc65bb74a6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.verification + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate +import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface +import org.matrix.rustcomponents.sdk.SessionVerificationEmoji +import javax.inject.Inject + +class RustSessionVerificationService @Inject constructor() : SessionVerificationService, SessionVerificationControllerDelegate { + + var verificationController: SessionVerificationControllerInterface? = null + set(value) { + field = value + _isReady.value = value != null + // If status was 'Unknown', move it to either 'Verified' or 'NotVerified' + if (value != null) { + value.setDelegate(this) + updateVerificationStatus(value.isVerified()) + } + } + + private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) + override val verificationFlowState = _verificationFlowState.asStateFlow() + + private val _isReady = MutableStateFlow(false) + override val isReady = _isReady.asStateFlow() + + private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) + override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow() + + override suspend fun requestVerification() = tryOrFail { + verificationController?.requestVerification() + } + + override suspend fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() } + + override suspend fun approveVerification() = tryOrFail { verificationController?.approveVerification() } + + override suspend fun declineVerification() = tryOrFail { verificationController?.declineVerification() } + + override suspend fun startVerification() = tryOrFail { + verificationController?.startSasVerification() + } + + private suspend fun tryOrFail(block: suspend () -> Unit) { + runCatching { + block() + }.onFailure { didFail() } + } + + // region Delegate implementation + + // When verification attempt is accepted by the other device + override fun didAcceptVerificationRequest() { + _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest + } + + override fun didCancel() { + _verificationFlowState.value = VerificationFlowState.Canceled + } + + override fun didFail() { + _verificationFlowState.value = VerificationFlowState.Failed + } + + override fun didFinish() { + _verificationFlowState.value = VerificationFlowState.Finished + // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false + updateVerificationStatus(isVerified = true) + } + + override fun didReceiveVerificationData(data: List<SessionVerificationEmoji>) { + val emojis = data.map { emoji -> + emoji.use { VerificationEmoji(it.symbol(), it.description()) } + } + _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojis) + } + + // When the actual SAS verification starts + override fun didStartSasVerification() { + _verificationFlowState.value = VerificationFlowState.StartedSasVerification + } + + // end-region + + override suspend fun reset() { + if (isReady.value) { + // Cancel any pending verification attempt + tryOrNull { verificationController?.cancelVerification() } + } + _verificationFlowState.value = VerificationFlowState.Initial + } + + fun destroy() { + (verificationController as? SessionVerificationController)?.destroy() + verificationController = null + } + + private fun updateVerificationStatus(isVerified: Boolean) { + val newValue = when { + !isReady.value -> SessionVerifiedStatus.Unknown + !isVerified -> SessionVerifiedStatus.NotVerified + else -> SessionVerifiedStatus.Verified + } + _sessionVerifiedStatus.value = newValue + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt new file mode 100644 index 0000000000..91f0bc1883 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.impl.timeline.postprocessor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.test.room.anEventTimelineItem +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import java.util.Date + +class TimelineEncryptedHistoryPostProcessorTest { + + private val defaultLastLoginTimestamp = Date(1689061264L) + + @Test + fun `given an unencrypted room, nothing is done`() { + val processor = createPostProcessor(isRoomEncrypted = false) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem()) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a null lastLoginTimestamp, nothing is done`() { + val processor = createPostProcessor(lastLoginTimestamp = null) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem()) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given an empty list, nothing is done`() { + val processor = createPostProcessor() + val items = emptyList<MatrixTimelineItem>() + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a list with no items before lastLoginTimestamp, nothing is done`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)) + ) + assertThat(processor.process(items)).isSameInstanceAs(items) + } + + @Test + fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)) + ) + assertThat(processor.process(items)) + .isEqualTo(listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))) + } + + @Test + fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() { + val processor = createPostProcessor() + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)) + ) + assertThat(processor.process(items)).isEqualTo( + listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)) + ) + } + + @Test + fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() { + val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) + val processor = createPostProcessor(paginationStateFlow = paginationStateFlow) + val items = listOf( + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)), + ) + assertThat(processor.process(items)).isEqualTo( + listOf( + MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner), + MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)) + ) + ) + assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false)) + } + + private fun createPostProcessor( + lastLoginTimestamp: Date? = defaultLastLoginTimestamp, + isRoomEncrypted: Boolean = true, + paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState> = + MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) + ) = TimelineEncryptedHistoryPostProcessor( + lastLoginTimestamp = lastLoginTimestamp, + isRoomEncrypted = isRoomEncrypted, + paginationStateFlow = paginationStateFlow, + ) +} diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts new file mode 100644 index 0000000000..4e8893aab6 --- /dev/null +++ b/libraries/matrix/test/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.matrix.test" +} + +dependencies { + api(projects.libraries.core) + api(projects.libraries.matrix.api) + api(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) +} diff --git a/libraries/matrix/test/src/main/AndroidManifest.xml b/libraries/matrix/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..dc2b81fddc --- /dev/null +++ b/libraries/matrix/test/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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" /> + +</manifest> diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt new file mode 100644 index 0000000000..1a654ac8d4 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.delay + +class FakeMatrixClient( + override val sessionId: SessionId = A_SESSION_ID, + private val userDisplayName: Result<String> = Result.success(A_USER_NAME), + private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), + override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), + override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + private val pushersService: FakePushersService = FakePushersService(), + private val notificationService: FakeNotificationService = FakeNotificationService(), + private val syncService: FakeSyncService = FakeSyncService(), +) : MatrixClient { + + private var ignoreUserResult: Result<Unit> = Result.success(Unit) + private var unignoreUserResult: Result<Unit> = Result.success(Unit) + private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID) + private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID) + private var createDmFailure: Throwable? = null + private var findDmResult: MatrixRoom? = FakeMatrixRoom() + private var logoutFailure: Throwable? = null + private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>() + private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>() + private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>() + private var uploadMediaResult: Result<String> = Result.success(AN_AVATAR_URL) + + override suspend fun getRoom(roomId: RoomId): MatrixRoom? { + return getRoomResults[roomId] + } + + override suspend fun findDM(userId: UserId): MatrixRoom? { + return findDmResult + } + + override suspend fun ignoreUser(userId: UserId): Result<Unit> = simulateLongTask { + return ignoreUserResult + } + + override suspend fun unignoreUser(userId: UserId): Result<Unit> = simulateLongTask { + return unignoreUserResult + } + + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> { + delay(100) + return createRoomResult + } + + override suspend fun createDM(userId: UserId): Result<RoomId> { + delay(100) + createDmFailure?.let { throw it } + return createDmResult + } + + override suspend fun getProfile(userId: UserId): Result<MatrixUser> { + return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId")) + } + + override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> { + return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm")) + } + + override fun syncService() = syncService + + override suspend fun getCacheSize(): Long { + return 0 + } + + override suspend fun clearCache() { + } + + override suspend fun logout() { + delay(100) + logoutFailure?.let { throw it } + } + + override fun close() = Unit + + override suspend fun loadUserDisplayName(): Result<String> { + return userDisplayName + } + + override suspend fun loadUserAvatarURLString(): Result<String?> { + return userAvatarURLString + } + + override suspend fun uploadMedia( + mimeType: String, + data: ByteArray, + progressCallback: ProgressCallback? + ): Result<String> { + return uploadMediaResult + } + + override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService + + override fun pushersService(): PushersService = pushersService + + override fun notificationService(): NotificationService = notificationService + + override fun roomMembershipObserver(): RoomMembershipObserver { + return RoomMembershipObserver() + } + + // Mocks + + fun givenLogoutError(failure: Throwable?) { + logoutFailure = failure + } + + fun givenCreateRoomResult(result: Result<RoomId>) { + createRoomResult = result + } + + fun givenCreateDmResult(result: Result<RoomId>) { + createDmResult = result + } + + fun givenIgnoreUserResult(result: Result<Unit>) { + ignoreUserResult = result + } + + fun givenUnignoreUserResult(result: Result<Unit>) { + unignoreUserResult = result + } + + fun givenCreateDmError(failure: Throwable?) { + createDmFailure = failure + } + + fun givenFindDmResult(result: MatrixRoom?) { + findDmResult = result + } + + fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) { + getRoomResults[roomId] = result + } + + fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) { + searchUserResults[searchTerm] = result + } + + fun givenGetProfileResult(userId: UserId, result: Result<MatrixUser>) { + getProfileResults[userId] = result + } + + fun givenUploadMediaResult(result: Result<String>) { + uploadMediaResult = result + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt new file mode 100644 index 0000000000..3da47a8563 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import java.util.UUID + +const val A_USER_NAME = "alice" +const val A_PASSWORD = "password" + +val A_USER_ID = UserId("@alice:server.org") +val A_USER_ID_2 = UserId("@bob:server.org") +val A_SESSION_ID: SessionId = A_USER_ID +val A_SESSION_ID_2: SessionId = A_USER_ID_2 +val A_SPACE_ID = SpaceId("!aSpaceId:domain") +val A_ROOM_ID = RoomId("!aRoomId:domain") +val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") +val A_THREAD_ID = ThreadId("\$aThreadId") +val AN_EVENT_ID = EventId("\$anEventId") +val AN_EVENT_ID_2 = EventId("\$anEventId2") +val A_TRANSACTION_ID = TransactionId("aTransactionId") +const val A_UNIQUE_ID = "aUniqueId" + +const val A_ROOM_NAME = "A room name" +const val A_MESSAGE = "Hello world!" +const val A_REPLY = "OK, I'll be there!" +const val ANOTHER_MESSAGE = "Hello universe!" + +const val A_HOMESERVER_URL = "matrix.org" +const val A_HOMESERVER_URL_2 = "matrix-client.org" + +val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false) +val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true) + +const val AN_AVATAR_URL = "mxc://data" + +const val A_FAILURE_REASON = "There has been a failure" + +val A_THROWABLE = Throwable(A_FAILURE_REASON) +val AN_EXCEPTION = Exception(A_FAILURE_REASON) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt new file mode 100644 index 0000000000..81fa3b677c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.auth + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf + +val A_OIDC_DATA = OidcDetails(url = "a-url") + +class FakeAuthenticationService : MatrixAuthenticationService { + private val homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null) + private var oidcError: Throwable? = null + private var oidcCancelError: Throwable? = null + private var loginError: Throwable? = null + private var changeServerError: Throwable? = null + + override fun isLoggedIn(): Flow<Boolean> { + return flowOf(false) + } + + override suspend fun getLatestSessionId(): SessionId? { + return null + } + + override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> { + return Result.failure(IllegalStateException()) + } + + override fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?> { + return homeserver + } + + fun givenHomeserver(homeserver: MatrixHomeServerDetails) { + this.homeserver.value = homeserver + } + + override suspend fun setHomeserver(homeserver: String): Result<Unit> = simulateLongTask { + changeServerError?.let { Result.failure(it) } ?: Result.success(Unit) + } + + override suspend fun login(username: String, password: String): Result<SessionId> = simulateLongTask { + loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) + } + + override suspend fun getOidcUrl(): Result<OidcDetails> = simulateLongTask { + oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) + } + + override suspend fun cancelOidcLogin(): Result<Unit> { + return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit) + } + + override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> = simulateLongTask { + loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) + } + + fun givenOidcError(throwable: Throwable?) { + oidcError = throwable + } + + fun givenOidcCancelError(throwable: Throwable?) { + oidcCancelError = throwable + } + + fun givenLoginError(throwable: Throwable?) { + loginError = throwable + } + + fun givenChangeServerError(throwable: Throwable?) { + changeServerError = throwable + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt new file mode 100644 index 0000000000..d048101f87 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.core + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType + +fun aBuildMeta( + buildType: BuildType = BuildType.DEBUG, + isDebuggable: Boolean = true, + applicationName: String = "", + applicationId: String = "", + lowPrivacyLoggingEnabled: Boolean = true, + versionName: String = "", + versionCode: Int = 0, + gitRevision: String = "", + gitRevisionDate: String = "", + gitBranchName: String = "", + flavorDescription: String = "", + flavorShortDescription: String = "", +) = BuildMeta( + buildType, + isDebuggable, + applicationName, + applicationId, + lowPrivacyLoggingEnabled, + versionName, + versionCode, + gitRevision, + gitRevisionDate, + gitBranchName, + flavorDescription, + flavorShortDescription +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt new file mode 100644 index 0000000000..275580d11e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaFile + +class FakeMediaFile(private val path: String) : MediaFile { + override fun path(): String { + return path + } + + override fun close() = Unit +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt new file mode 100644 index 0000000000..9ef0413a3a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.tests.testutils.simulateLongTask + +class FakeMediaLoader : MatrixMediaLoader { + + var shouldFail = false + + override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(ByteArray(0)) + } + } + + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result<ByteArray> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(ByteArray(0)) + } + } + + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(FakeMediaFile("")) + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt new file mode 100644 index 0000000000..4a0e9005d2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaSource + +fun aMediaSource(url: String = "") = MediaSource( + url = url, + json = null +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt new file mode 100644 index 0000000000..9eb5a20ba4 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.notification + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.notification.NotificationService + +class FakeNotificationService : NotificationService { + override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result<NotificationData?> { + return Result.success(null) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt new file mode 100644 index 0000000000..6ff7e4a20b --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.pushers + +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData + +class FakePushersService : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) + override suspend fun unsetHttpPusher(): Result<Unit> = Result.success(Unit) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt new file mode 100644 index 0000000000..7fe7de5b9f --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +class FakeMatrixRoom( + override val sessionId: SessionId = A_SESSION_ID, + override val roomId: RoomId = A_ROOM_ID, + override val name: String? = null, + override val displayName: String = "", + override val topic: String? = null, + override val avatarUrl: String? = null, + override val isEncrypted: Boolean = false, + override val alias: String? = null, + override val alternativeAliases: List<String> = emptyList(), + override val isPublic: Boolean = true, + override val isDirect: Boolean = false, + override val joinedMemberCount: Long = 123L, + override val activeMemberCount: Long = 234L, + private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), +) : MatrixRoom { + + private var ignoreResult: Result<Unit> = Result.success(Unit) + private var unignoreResult: Result<Unit> = Result.success(Unit) + private var userDisplayNameResult = Result.success<String?>(null) + private var userAvatarUrlResult = Result.success<String?>(null) + private var updateMembersResult: Result<Unit> = Result.success(Unit) + private var joinRoomResult = Result.success(Unit) + private var inviteUserResult = Result.success(Unit) + private var canInviteResult = Result.success(true) + private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>() + private val canSendEventResults = mutableMapOf<MessageEventType, Result<Boolean>>() + private var sendMediaResult = Result.success(Unit) + private var setNameResult = Result.success(Unit) + private var setTopicResult = Result.success(Unit) + private var updateAvatarResult = Result.success(Unit) + private var removeAvatarResult = Result.success(Unit) + private var toggleReactionResult = Result.success(Unit) + private var retrySendMessageResult = Result.success(Unit) + private var cancelSendResult = Result.success(Unit) + private var forwardEventResult = Result.success(Unit) + private var reportContentResult = Result.success(Unit) + private var sendLocationResult = Result.success(Unit) + private var progressCallbackValues = emptyList<Pair<Long, Long>>() + val editMessageCalls = mutableListOf<String>() + + var sendMediaCount = 0 + private set + + private val _myReactions = mutableSetOf<String>() + val myReactions: Set<String> = _myReactions + + var retrySendMessageCount: Int = 0 + private set + + var cancelSendCount: Int = 0 + private set + + var reportedContentCount: Int = 0 + private set + + private val _sentLocations = mutableListOf<SendLocationInvocation>() + val sentLocations: List<SendLocationInvocation> = _sentLocations + + + var invitedUserId: UserId? = null + private set + + var newTopic: String? = null + private set + + var newName: String? = null + private set + + var newAvatarData: ByteArray? = null + private set + + var removedAvatar: Boolean = false + private set + + private var leaveRoomError: Throwable? = null + + override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown) + + override suspend fun updateMembers(): Result<Unit> = simulateLongTask { + updateMembersResult + } + + override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L) + + override val timeline: MatrixTimeline = matrixTimeline + + override fun open(): Result<Unit> { + return Result.success(Unit) + } + + override suspend fun userDisplayName(userId: UserId): Result<String?> = simulateLongTask { + userDisplayNameResult + } + + override suspend fun userAvatarUrl(userId: UserId): Result<String?> = simulateLongTask { + userAvatarUrlResult + } + + override suspend fun sendMessage(message: String): Result<Unit> = simulateLongTask { + Result.success(Unit) + } + + override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> { + if (toggleReactionResult.isFailure) { + // Don't do the toggle if we failed + return toggleReactionResult + } + + if (_myReactions.contains(emoji)) { + _myReactions.remove(emoji) + } else { + _myReactions.add(emoji) + } + + return toggleReactionResult + } + + override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> { + retrySendMessageCount++ + return retrySendMessageResult + } + + override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> { + cancelSendCount++ + return cancelSendResult + } + + override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> { + editMessageCalls += message + return Result.success(Unit) + } + + var replyMessageParameter: String? = null + private set + + override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> { + replyMessageParameter = message + return Result.success(Unit) + } + + var redactEventEventIdParam: EventId? = null + private set + + override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> { + redactEventEventIdParam = eventId + return Result.success(Unit) + } + + override suspend fun leave(): Result<Unit> = + leaveRoomError?.let { Result.failure(it) } ?: Result.success(Unit) + + override suspend fun join(): Result<Unit> { + return joinRoomResult + } + + override suspend fun inviteUserById(id: UserId): Result<Unit> = simulateLongTask { + invitedUserId = id + inviteUserResult + } + + override suspend fun canUserInvite(userId: UserId): Result<Boolean> { + return canInviteResult + } + + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> { + return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer")) + } + + override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> { + return canSendEventResults[type] ?: Result.failure(IllegalStateException("No fake answer")) + } + + override suspend fun sendImage( + file: File, + thumbnailFile: File, + imageInfo: ImageInfo, + progressCallback: ProgressCallback? + ): Result<Unit> = fakeSendMedia(progressCallback) + + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia( + progressCallback + ) + + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia(progressCallback) + + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia(progressCallback) + + override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = simulateLongTask { + forwardEventResult + } + + private suspend fun fakeSendMedia(progressCallback: ProgressCallback?): Result<Unit> = simulateLongTask { + sendMediaResult.onSuccess { + progressCallbackValues.forEach { (current, total) -> + progressCallback?.onProgress(current, total) + delay(1) + } + sendMediaCount++ + } + } + + override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = simulateLongTask { + newAvatarData = data + updateAvatarResult + } + + override suspend fun removeAvatar(): Result<Unit> = simulateLongTask { + removedAvatar = true + removeAvatarResult + } + + override suspend fun setName(name: String): Result<Unit> = simulateLongTask { + newName = name + setNameResult + } + + override suspend fun setTopic(topic: String): Result<Unit> = simulateLongTask { + newTopic = topic + setTopicResult + } + + override suspend fun reportContent( + eventId: EventId, + reason: String, + blockUserId: UserId? + ): Result<Unit> = simulateLongTask { + reportedContentCount++ + return reportContentResult + } + + override suspend fun sendLocation( + body: String, + geoUri: String, + description: String?, + zoomLevel: Int?, + assetType: AssetType?, + ): Result<Unit> = simulateLongTask { + _sentLocations.add(SendLocationInvocation(body, geoUri, description, zoomLevel, assetType)) + return sendLocationResult + } + + override fun close() = Unit + + fun givenLeaveRoomError(throwable: Throwable?) { + this.leaveRoomError = throwable + } + + fun givenRoomMembersState(state: MatrixRoomMembersState) { + membersStateFlow.value = state + } + + fun givenUpdateMembersResult(result: Result<Unit>) { + updateMembersResult = result + } + + fun givenUserDisplayNameResult(displayName: Result<String?>) { + userDisplayNameResult = displayName + } + + fun givenUserAvatarUrlResult(avatarUrl: Result<String?>) { + userAvatarUrlResult = avatarUrl + } + + fun givenJoinRoomResult(result: Result<Unit>) { + joinRoomResult = result + } + + fun givenInviteUserResult(result: Result<Unit>) { + inviteUserResult = result + } + + fun givenCanInviteResult(result: Result<Boolean>) { + canInviteResult = result + } + + fun givenCanSendStateResult(type: StateEventType, result: Result<Boolean>) { + canSendStateResults[type] = result + } + + fun givenCanSendEventResult(type: MessageEventType, result: Result<Boolean>) { + canSendEventResults[type] = result + } + + fun givenIgnoreResult(result: Result<Unit>) { + ignoreResult = result + } + + fun givenUnIgnoreResult(result: Result<Unit>) { + unignoreResult = result + } + + fun givenSendMediaResult(result: Result<Unit>) { + sendMediaResult = result + } + + fun givenUpdateAvatarResult(result: Result<Unit>) { + updateAvatarResult = result + } + + fun givenRemoveAvatarResult(result: Result<Unit>) { + removeAvatarResult = result + } + + fun givenSetNameResult(result: Result<Unit>) { + setNameResult = result + } + + fun givenSetTopicResult(result: Result<Unit>) { + setTopicResult = result + } + + fun givenToggleReactionResult(result: Result<Unit>) { + toggleReactionResult = result + } + + fun givenRetrySendMessageResult(result: Result<Unit>) { + retrySendMessageResult = result + } + + fun givenCancelSendResult(result: Result<Unit>) { + cancelSendResult = result + } + + fun givenForwardEventResult(result: Result<Unit>) { + forwardEventResult = result + } + + fun givenReportContentResult(result: Result<Unit>) { + reportContentResult = result + } + + fun givenSendLocationResult(result: Result<Unit>) { + sendLocationResult = result + } + + fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) { + progressCallbackValues = values + } +} + +data class SendLocationInvocation( + val body: String, + val geoUri: String, + val description: String?, + val zoomLevel: Int?, + val assetType: AssetType?, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt new file mode 100644 index 0000000000..cae36e14c8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeRoomSummaryDataSource : RoomSummaryDataSource { + + private val allRoomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList()) + private val inviteRoomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList()) + private val allRoomsLoadingStateFlow = MutableStateFlow<RoomSummaryDataSource.LoadingState>(RoomSummaryDataSource.LoadingState.NotLoaded) + + suspend fun postAllRooms(roomSummaries: List<RoomSummary>) { + allRoomSummariesFlow.emit(roomSummaries) + } + + suspend fun postInviteRooms(roomSummaries: List<RoomSummary>) { + inviteRoomSummariesFlow.emit(roomSummaries) + } + + suspend fun postLoadingState(loadingState: RoomSummaryDataSource.LoadingState) { + allRoomsLoadingStateFlow.emit(loadingState) + } + + override fun allRoomsLoadingState(): StateFlow<RoomSummaryDataSource.LoadingState> { + return allRoomsLoadingStateFlow + } + + override fun allRooms(): StateFlow<List<RoomSummary>> { + return allRoomSummariesFlow + } + + override fun inviteRooms(): StateFlow<List<RoomSummary>> { + return inviteRoomSummariesFlow + } + + var latestSlidingSyncRange: IntRange? = null + private set + + override fun updateAllRoomsVisibleRange(range: IntRange) { + latestSlidingSyncRange = range + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt new file mode 100644 index 0000000000..8d43459aa8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + normalizedPowerLevel: Long = 0L, + isIgnored: Boolean = false, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt new file mode 100644 index 0000000000..4df815c54c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.room.message.RoomMessage +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME + +fun aRoomSummaryFilled( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDirect: Boolean = false, + avatarURLString: String? = null, + lastMessage: RoomMessage? = aRoomMessage(), + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 2, +) = RoomSummary.Filled( + aRoomSummaryDetail( + roomId = roomId, + name = name, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, + ) +) + +fun aRoomSummaryDetail( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDirect: Boolean = false, + avatarURLString: String? = null, + lastMessage: RoomMessage? = aRoomMessage(), + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 2, +) = RoomSummaryDetails( + roomId = roomId, + name = name, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, +) + +fun aRoomMessage( + eventId: EventId = AN_EVENT_ID, + event: EventTimelineItem = anEventTimelineItem(), + userId: UserId = A_USER_ID, + timestamp: Long = 0L, +) = RoomMessage( + eventId = eventId, + event = event, + sender = userId, + originServerTs = timestamp, +) + +fun anEventTimelineItem( + eventId: EventId = AN_EVENT_ID, + transactionId: TransactionId? = null, + isEditable: Boolean = false, + isLocal: Boolean = false, + isOwn: Boolean = false, + isRemote: Boolean = false, + localSendState: LocalEventSendState? = null, + reactions: List<EventReaction> = emptyList(), + sender: UserId = A_USER_ID, + senderProfile: ProfileTimelineDetails = aProfileTimelineDetails(), + timestamp: Long = 0L, + content: EventContent = aProfileChangeMessageContent(), + debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), +) = EventTimelineItem( + eventId = eventId, + transactionId = transactionId, + isEditable = isEditable, + isLocal = isLocal, + isOwn = isOwn, + isRemote = isRemote, + localSendState = localSendState, + reactions = reactions, + sender = sender, + senderProfile = senderProfile, + timestamp = timestamp, + content = content, + debugInfo = debugInfo, + origin = null, +) + +fun aProfileTimelineDetails( + displayName: String? = A_USER_NAME, + displayNameAmbiguous: Boolean = false, + avatarUrl: String? = null +): ProfileTimelineDetails = ProfileTimelineDetails.Ready( + displayName = displayName, + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = avatarUrl, +) + +fun aProfileChangeMessageContent( + displayName: String? = null, + prevDisplayName: String? = null, + avatarUrl: String? = null, + prevAvatarUrl: String? = null, +) = ProfileChangeContent( + displayName = displayName, + prevDisplayName = prevDisplayName, + avatarUrl = avatarUrl, + prevAvatarUrl = prevAvatarUrl, +) + +fun aMessageContent( + body: String = "body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + type = messageType +) + +fun aTimelineItemDebugInfo( + model: String = "Rust(Model())", + originalJson: String? = null, + latestEditedJson: String? = null, +) = TimelineItemDebugInfo( + model, originalJson, latestEditedJson +) + diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt new file mode 100644 index 0000000000..dd653a76ec --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.sync + +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeSyncService : SyncService { + + private val syncStateFlow = MutableStateFlow(SyncState.Idle) + + fun simulateError() { + syncStateFlow.value = SyncState.Error + } + + override suspend fun startSync(): Result<Unit> { + syncStateFlow.value = SyncState.Running + return Result.success(Unit) + } + + override fun stopSync(): Result<Unit> { + syncStateFlow.value = SyncState.Terminated + return Result.success(Unit) + } + + override val syncState: StateFlow<SyncState> = syncStateFlow +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt new file mode 100644 index 0000000000..73bc5fb597 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeMatrixTimeline.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.MatrixTimeline +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate + +class FakeMatrixTimeline( + initialTimelineItems: List<MatrixTimelineItem> = emptyList(), + initialPaginationState: MatrixTimeline.PaginationState = MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false) +) : MatrixTimeline { + + private val _paginationState: MutableStateFlow<MatrixTimeline.PaginationState> = MutableStateFlow(initialPaginationState) + private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> = MutableStateFlow(initialTimelineItems) + + var sendReadReceiptCount = 0 + private set + + var sendReadReceiptLatch: CompletableDeferred<Unit>? = null + + fun updatePaginationState(update: (MatrixTimeline.PaginationState.() -> MatrixTimeline.PaginationState)) { + _paginationState.getAndUpdate(update) + } + + fun updateTimelineItems(update: (items: List<MatrixTimelineItem>) -> List<MatrixTimelineItem>) { + _timelineItems.getAndUpdate(update) + } + + override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState + + override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems + + override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> { + updatePaginationState { + copy(isBackPaginating = true) + } + delay(100) + updatePaginationState { + copy(isBackPaginating = false) + } + updateTimelineItems { timelineItems -> + timelineItems + } + return Result.success(Unit) + } + + override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask { + Result.success(Unit) + } + + override suspend fun sendReadReceipt(eventId: EventId): Result<Unit> = simulateLongTask { + sendReadReceiptCount++ + sendReadReceiptLatch?.complete(Unit) + Result.success(Unit) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt new file mode 100644 index 0000000000..2f7887c537 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.test.verification + +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationEmoji +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeSessionVerificationService : SessionVerificationService { + private val _isReady = MutableStateFlow(false) + private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown) + private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial) + private var emojiList = emptyList<VerificationEmoji>() + var shouldFail = false + + override val verificationFlowState: StateFlow<VerificationFlowState> + get() = _verificationFlowState + + override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus + + override val isReady: StateFlow<Boolean> = _isReady + + override suspend fun requestVerification() { + _verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest + } + + override suspend fun cancelVerification() { + _verificationFlowState.value = VerificationFlowState.Canceled + } + + override suspend fun approveVerification() { + if (!shouldFail) { + _verificationFlowState.value = VerificationFlowState.Finished + } else { + _verificationFlowState.value = VerificationFlowState.Failed + } + } + + override suspend fun declineVerification() { + if (!shouldFail) { + _verificationFlowState.value = VerificationFlowState.Canceled + } else { + _verificationFlowState.value = VerificationFlowState.Failed + } + } + + fun triggerReceiveVerificationData() { + _verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList) + } + + override suspend fun startVerification() { + _verificationFlowState.value = VerificationFlowState.StartedSasVerification + } + + fun givenVerifiedStatus(status: SessionVerifiedStatus) { + _sessionVerifiedStatus.value = status + } + + fun givenVerificationFlowState(state: VerificationFlowState) { + _verificationFlowState.value = state + } + + fun givenIsReady(value: Boolean) { + _isReady.value = value + } + + fun givenEmojiList(emojis: List<VerificationEmoji>) { + this.emojiList = emojis + } + + override suspend fun reset() { + _verificationFlowState.value = VerificationFlowState.Initial + } +} diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts new file mode 100644 index 0000000000..7302eeddfb --- /dev/null +++ b/libraries/matrixui/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.matrix.ui" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.di) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.core) + implementation(projects.libraries.uiStrings) + implementation(libs.coil.compose) + implementation(libs.coil.gif) + + ksp(libs.showkase.processor) +} diff --git a/libraries/matrixui/src/main/AndroidManifest.xml b/libraries/matrixui/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..122869829c --- /dev/null +++ b/libraries/matrixui/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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/> diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt new file mode 100644 index 0000000000..71883ebc20 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import android.os.Parcelable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.material.icons.outlined.VideoCameraBack +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import io.element.android.libraries.designsystem.components.BlurHashAsyncImage +import io.element.android.libraries.designsystem.components.PinIcon +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import kotlinx.parcelize.Parcelize + +@Composable +fun AttachmentThumbnail( + info: AttachmentThumbnailInfo, + modifier: Modifier = Modifier, + thumbnailSize: Long = 32L, + backgroundColor: Color = MaterialTheme.colorScheme.surface, +) { + if (info.thumbnailSource != null) { + val mediaRequestData = MediaRequestData( + source = info.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail(thumbnailSize), + ) + BlurHashAsyncImage( + model = mediaRequestData, + blurHash = info.blurHash, + contentDescription = info.textContent, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else { + Box( + modifier = modifier.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + when (info.type) { + AttachmentThumbnailType.Video -> { + Icon( + imageVector = Icons.Outlined.VideoCameraBack, + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.Audio -> { + Icon( + imageVector = Icons.Outlined.GraphicEq, + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.File -> { + Icon( + imageVector = Icons.Outlined.Attachment, + contentDescription = info.textContent, + modifier = Modifier.rotate(-45f) + ) + } + AttachmentThumbnailType.Location -> { + PinIcon( + modifier = Modifier.fillMaxSize() + ) + } + else -> Unit + } + } + } +} + +@Parcelize +enum class AttachmentThumbnailType: Parcelable { + Image, Video, File, Audio, Location +} + +@Parcelize +data class AttachmentThumbnailInfo( + val type: AttachmentThumbnailType, + val thumbnailSource: MediaSource? = null, + val textContent: String? = null, + val blurHash: String? = null, +): Parcelable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt new file mode 100644 index 0000000000..b12f577c36 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalMaterialApi::class) + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch + +@Composable +fun AvatarActionBottomSheet( + actions: ImmutableList<AvatarAction>, + modalBottomSheetState: ModalBottomSheetState, + modifier: Modifier = Modifier, + onActionSelected: (action: AvatarAction) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + fun onItemActionClicked(itemAction: AvatarAction) { + onActionSelected(itemAction) + coroutineScope.launch { + modalBottomSheetState.hide() + } + } + + ModalBottomSheetLayout( + modifier = modifier, + sheetState = modalBottomSheetState, + displayHandle = true, + sheetContent = { + AvatarActionBottomSheetContent( + actions = actions, + onActionClicked = ::onItemActionClicked, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) + } + ) +} + +@Composable +private fun AvatarActionBottomSheetContent( + actions: ImmutableList<AvatarAction>, + modifier: Modifier = Modifier, + onActionClicked: (AvatarAction) -> Unit = { }, +) { + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { onActionClicked(action) }, + headlineContent = { + Text( + text = stringResource(action.titleResId), + style = ElementTheme.typography.fontBodyLgRegular, + color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + }, + leadingContent = { + Icon( + imageVector = action.icon, + contentDescription = stringResource(action.titleResId), + tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary, + ) + } + ) + } + } +} + +@Preview +@Composable +fun AvatarActionBottomSheetLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun AvatarActionBottomSheetDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AvatarActionBottomSheet( + actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), + modalBottomSheetState = ModalBottomSheetState( + initialValue = ModalBottomSheetValue.Expanded + ), + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt new file mode 100644 index 0000000000..518fb21fe1 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableMatrixUserRow.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName + +@Composable +fun CheckableMatrixUserRow( + checked: Boolean, + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + avatarSize: AvatarSize = AvatarSize.UserListItem, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, +) = CheckableUserRow( + checked = checked, + avatarData = matrixUser.getAvatarData(avatarSize), + name = matrixUser.getBestName(), + subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, + modifier = modifier, + onCheckedChange = onCheckedChange, + enabled = enabled, +) + +@Composable +fun CheckableUserRow( + checked: Boolean, + avatarData: AvatarData, + name: String, + subtext: String?, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(role = Role.Checkbox, enabled = enabled) { + onCheckedChange(!checked) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + UserRow( + modifier = Modifier.weight(1f), + avatarData = avatarData, + name = name, + subtext = subtext, + ) + + Checkbox( + modifier = Modifier + .padding(end = 16.dp), + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } +} + +@Preview +@Composable +internal fun CheckableMatrixUserRowLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@Preview +@Composable +internal fun CheckableMatrixUserRowDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@Composable +private fun ContentToPreview(matrixUser: MatrixUser) { + Column { + CheckableMatrixUserRow(checked = true, matrixUser) + CheckableMatrixUserRow(checked = false, matrixUser) + CheckableMatrixUserRow(checked = true, matrixUser, enabled = false) + CheckableMatrixUserRow(checked = false, matrixUser, enabled = false) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt new file mode 100644 index 0000000000..6054aa53af --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun MatrixUserHeader( + matrixUser: MatrixUser?, + modifier: Modifier = Modifier, + // TODO handle click on this item, to let the user be able to update their profile. + // onClick: () -> Unit = {}, +) { + if (matrixUser == null) { + MatrixUserHeaderPlaceholder(modifier = modifier) + } else { + MatrixUserHeaderContent( + matrixUser = matrixUser, + modifier = modifier, + // onClick = onClick + ) + } +} + +@Composable +private fun MatrixUserHeaderContent( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + // onClick: () -> Unit = {}, +) { + Row( + modifier = modifier + // .clickable(onClick = onClick) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + modifier = Modifier + .padding(vertical = 12.dp), + avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + // Name + Text( + text = matrixUser.getBestName(), + maxLines = 1, + style = ElementTheme.typography.fontHeadingSmMedium, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.materialColors.primary, + ) + // Id + if (matrixUser.displayName.isNullOrEmpty().not()) { + Text( + text = matrixUser.userId.value, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.materialColors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Preview +@Composable +fun MatrixUserHeaderLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@Preview +@Composable +fun MatrixUserHeaderDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@Composable +private fun ContentToPreview(matrixUser: MatrixUser) { + MatrixUserHeader(matrixUser) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt new file mode 100644 index 0000000000..57901edc03 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun MatrixUserHeaderPlaceholder( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .size(AvatarSize.UserPreference.dp) + .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + PlaceholderAtom(width = 80.dp, height = 7.dp) + Spacer(modifier = Modifier.height(16.dp)) + PlaceholderAtom(width = 180.dp, height = 6.dp) + } + } +} + +@Preview +@Composable +fun MatrixUserHeaderPlaceholderLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun MatrixUserHeaderPlaceholderDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + MatrixUserHeaderPlaceholder() +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt new file mode 100644 index 0000000000..923afd94ad --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser + +open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> { + override val values: Sequence<MatrixUser> + get() = sequenceOf( + aMatrixUser(), + aMatrixUser().copy(displayName = null), + ) +} + +fun aMatrixUser(id: String = "@id_of_alice:server.org", displayName: String = "Alice") = MatrixUser( + userId = UserId(id), + displayName = displayName, +) + +fun aMatrixUserList() = listOf( + aMatrixUser("@alice:server.org", "Alice"), + aMatrixUser("@bob:server.org", "Bob"), + aMatrixUser("@carol:server.org", "Carol"), + aMatrixUser("@david:server.org", "David"), + aMatrixUser("@eve:server.org", "Eve"), + aMatrixUser("@justin:server.org", "Justin"), + aMatrixUser("@mallory:server.org", "Mallory"), + aMatrixUser("@susie:server.org", "Susie"), + aMatrixUser("@victor:server.org", "Victor"), + aMatrixUser("@walter:server.org", "Walter"), +) + +open class MatrixUserWithNullProvider : PreviewParameterProvider<MatrixUser?> { + override val values: Sequence<MatrixUser?> + get() = sequenceOf( + aMatrixUser(), + aMatrixUser().copy(displayName = null), + null, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt new file mode 100644 index 0000000000..298397e60d --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun MatrixUserRow( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + avatarSize: AvatarSize = AvatarSize.UserListItem, +) = UserRow( + avatarData = matrixUser.getAvatarData(avatarSize), + name = matrixUser.getBestName(), + subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, + modifier = modifier, +) + +@Composable +fun UserRow( + avatarData: AvatarData, + name: String, + subtext: String?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(avatarData) + Column( + modifier = Modifier + .padding(start = 12.dp), + ) { + // Name + Text( + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyLgRegular, + ) + // Id + subtext?.let { + Text( + text = subtext, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + } +} + +@Preview +@Composable +internal fun MatrixUserRowLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@Preview +@Composable +internal fun MatrixUserRowDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@Composable +private fun ContentToPreview(matrixUser: MatrixUser) { + MatrixUserRow(matrixUser) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt new file mode 100644 index 0000000000..2881235335 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SelectedRoom( + roomSummary: RoomSummaryDetails, + modifier: Modifier = Modifier, + onRoomRemoved: (RoomSummaryDetails) -> Unit = {}, +) { + Box( + modifier = modifier + .width(56.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarURLString, AvatarSize.SelectedRoom)) + Text( + text = roomSummary.name, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge, + ) + } + Surface( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(CircleShape) + .size(20.dp) + .align(Alignment.TopEnd) + .clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onRoomRemoved(roomSummary) } + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = CommonStrings.action_remove), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(2.dp) + ) + } + } +} + +@Preview +@Composable +internal fun SelectedRoomLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedRoomDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SelectedRoom( + roomSummary = RoomSummaryDetails( + roomId = RoomId("!room:domain"), + name = "roomName", + canonicalAlias = null, + isDirect = true, + avatarURLString = null, + lastMessage = null, + lastMessageTimestamp = null, + unreadNotificationCount = 0, + inviter = null, + ) + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt new file mode 100644 index 0000000000..ad5b2613cb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SelectedUser( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + onUserRemoved: (MatrixUser) -> Unit = {}, +) { + Box( + modifier = modifier + .width(AvatarSize.SelectedUser.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar(matrixUser.getAvatarData(size = AvatarSize.SelectedUser)) + Text( + text = matrixUser.getBestName(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge, + ) + } + Surface( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .clip(CircleShape) + .size(20.dp) + .align(Alignment.TopEnd) + .clickable( + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = { onUserRemoved(matrixUser) } + ), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = CommonStrings.action_remove), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(2.dp) + ) + } + } +} + +@Preview +@Composable +internal fun SelectedUserLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedUserDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SelectedUser(aMatrixUser()) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersList.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersList.kt new file mode 100644 index 0000000000..c0ed0460dc --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersList.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.math.floor + +@Composable +fun SelectedUsersList( + selectedUsers: ImmutableList<MatrixUser>, + modifier: Modifier = Modifier, + autoScroll: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + onUserRemoved: (MatrixUser) -> Unit = {}, +) { + val lazyListState = rememberLazyListState() + if (autoScroll) { + var currentSize by rememberSaveable { mutableStateOf(selectedUsers.size) } + LaunchedEffect(selectedUsers.size) { + val isItemAdded = selectedUsers.size > currentSize + if (isItemAdded) { + lazyListState.animateScrollToItem(selectedUsers.lastIndex) + } + currentSize = selectedUsers.size + } + } + + val rowWidth by remember { + derivedStateOf { + lazyListState.layoutInfo.viewportSize.width - lazyListState.layoutInfo.beforeContentPadding + } + } + + // Calculate spacing to show between each user. This is at least [minimumSpacing], and will grow to ensure that if the available space is filled with + // users, the last visible user will be precisely half visible. This gives an obvious affordance that there are more entries and the list can be scrolled. + // For efficiency, we assume that all the children are the same width. If they needed to be different sizes we'd have to do this calculation each time + // they needed to be measured. + val minimumSpacing = 24.dp.toPx() + val userWidth = 56.dp.toPx() + val userSpacing by remember { + derivedStateOf { + if (rowWidth == 0) { + // The row hasn't yet been measured yet, so we don't know how big it is + minimumSpacing + } else { + val userWidthWithSpacing = userWidth + minimumSpacing + val maxVisibleUsers = rowWidth / userWidthWithSpacing + + // Round down the number of visible users to end with a state where one is half visible + val targetFraction = (userWidth / 2) / userWidthWithSpacing + val targetUsers = floor(maxVisibleUsers - targetFraction) + targetFraction + + // Work out how much extra spacing we need to reduce the number of users that much, then split it evenly amongst the visible users + val extraSpacing = (maxVisibleUsers - targetUsers) * userWidthWithSpacing + val extraSpacingPerUser = extraSpacing / floor(targetUsers) + + minimumSpacing + extraSpacingPerUser + } + } + } + + LazyRow( + state = lazyListState, + modifier = modifier + .fillMaxWidth(), + contentPadding = contentPadding, + ) { + itemsIndexed(selectedUsers.toList()) { index, matrixUser -> + Layout( + content = { + SelectedUser( + matrixUser = matrixUser, + onUserRemoved = onUserRemoved, + ) + }, + measurePolicy = { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + val spacing = if (index == selectedUsers.lastIndex) 0f else userSpacing + layout( + width = (placeable.width + spacing).toInt(), + height = placeable.height + ) { + placeable.place(0, 0) + } + } + ) + } + } +} + +@Preview +@Composable +internal fun SelectedUsersListLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Two users that will be visible with no scrolling + SelectedUsersList( + selectedUsers = aMatrixUserList().take(2).toImmutableList(), + modifier = Modifier + .width(200.dp) + .border(1.dp, Color.Red) + ) + + // Multiple users that don't fit, so will be spaced out per the measure policy + for (i in 0..5) { + SelectedUsersList( + selectedUsers = aMatrixUserList().take(6).toImmutableList(), + modifier = Modifier + .width((200 + (i * 20)).dp) + .border(1.dp, Color.Red) + ) + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt new file mode 100644 index 0000000000..de4d575de0 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun UnresolvedUserRow( + avatarData: AvatarData, + id: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(avatarData) + Column( + modifier = Modifier + .padding(start = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // ID + Text( + text = id, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + + // Warning + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 3.dp) + ) { + Icon( + imageVector = Icons.Filled.Error, + contentDescription = "", + modifier = Modifier + .size(18.dp) + .align(Alignment.Top) + .padding(2.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Text( + text = stringResource(CommonStrings.common_invite_unknown_profile), + color = MaterialTheme.colorScheme.secondary, + style = ElementTheme.typography.fontBodySmRegular.copy(lineHeight = 16.sp), + ) + } + } + } +} + +@Composable +fun CheckableUnresolvedUserRow( + checked: Boolean, + avatarData: AvatarData, + id: String, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit = {}, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(role = Role.Checkbox, enabled = enabled) { + onCheckedChange(!checked) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + UnresolvedUserRow( + modifier = Modifier.weight(1f), + avatarData = avatarData, + id = id, + ) + + Checkbox( + modifier = Modifier.padding(end = 16.dp), + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + } +} + +@Preview +@Composable +internal fun UnresolvedUserRowPreview() = + ElementThemedPreview { + val matrixUser = aMatrixUser() + UnresolvedUserRow(matrixUser.getAvatarData(size = AvatarSize.UserListItem), matrixUser.userId.value) + } + +@Preview +@Composable +internal fun CheckableUnresolvedUserRowPreview() = + ElementThemedPreview { + val matrixUser = aMatrixUser() + Column { + CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value) + Divider() + CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value) + Divider() + CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false) + Divider() + CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false) + } + } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt new file mode 100644 index 0000000000..2b5d2f6800 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.components + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddAPhoto +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial +import io.element.android.libraries.theme.ElementTheme + +/** + * An avatar that the user has selected, but which has not yet been uploaded to Matrix. + * + * The image is loaded from a local resource instead of from a MXC URI. + */ +@Composable +fun UnsavedAvatar( + avatarUri: Uri?, + modifier: Modifier = Modifier, +) { + val commonModifier = modifier + .size(70.dp) + .clip(CircleShape) + + if (avatarUri != null) { + val context = LocalContext.current + val model = ImageRequest.Builder(context) + .data(avatarUri) + .build() + AsyncImage( + modifier = commonModifier, + model = model, + placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) { + Icon( + imageVector = Icons.Outlined.AddAPhoto, + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .size(40.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + } + } +} + +@Preview +@Composable +fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Row { + UnsavedAvatar(null) + UnsavedAvatar(Uri.EMPTY) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixUIBindings.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixUIBindings.kt new file mode 100644 index 0000000000..919971b307 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixUIBindings.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.ui.media.LoggedInImageLoaderFactory + +@ContributesTo(SessionScope::class) +interface MatrixUIBindings { + fun loggedInImageLoaderFactory(): LoggedInImageLoaderFactory +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarAction.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarAction.kt new file mode 100644 index 0000000000..0a178f2c25 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarAction.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +sealed class AvatarAction( + @StringRes val titleResId: Int, + val icon: ImageVector, + val destructive: Boolean = false, +) { + object TakePhoto : AvatarAction(titleResId = CommonStrings.action_take_photo, icon = Icons.Outlined.PhotoCamera) + object ChoosePhoto : AvatarAction(titleResId = CommonStrings.action_choose_photo, icon = Icons.Outlined.PhotoLibrary) + object Remove : AvatarAction(titleResId = CommonStrings.action_remove, icon = Icons.Outlined.Delete, destructive = true) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt new file mode 100644 index 0000000000..39912cb443 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatatarDataExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.math.roundToLong + +fun AvatarData.toMediaRequestData(): MediaRequestData { + return MediaRequestData( + source = url?.let { MediaSource(it) }, + kind = MediaRequestData.Kind.Thumbnail(size.dp.value.roundToLong()) + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt new file mode 100644 index 0000000000..8b421c6c28 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.request.Options +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.media.toFile +import okio.Buffer +import okio.Path.Companion.toOkioPath +import timber.log.Timber +import java.nio.ByteBuffer + +internal class CoilMediaFetcher( + private val mediaLoader: MatrixMediaLoader, + private val mediaData: MediaRequestData?, + private val options: Options +) : Fetcher { + + override suspend fun fetch(): FetchResult? { + if (mediaData?.source == null) return null + return when (mediaData.kind) { + is MediaRequestData.Kind.Content -> fetchContent(mediaData.source, options) + is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaData.source, mediaData.kind, options) + is MediaRequestData.Kind.File -> fetchFile(mediaData.source, mediaData.kind) + } + } + + /** + * This method is here to avoid using [MatrixMediaLoader.loadMediaContent] as too many ByteArray allocations will flood the memory and cause lots of GC. + * The MediaFile will be closed (and so destroyed from disk) when the image source is closed. + * + */ + private suspend fun fetchFile(mediaSource: MediaSource, kind: MediaRequestData.Kind.File): FetchResult? { + return mediaLoader.downloadMediaFile(mediaSource, kind.mimeType, kind.body) + .map { mediaFile -> + val file = mediaFile.toFile() + SourceResult( + source = ImageSource(file = file.toOkioPath(), closeable = mediaFile), + mimeType = null, + dataSource = DataSource.DISK + ) + } + .onFailure { + Timber.e(it) + } + .getOrNull() + } + + private suspend fun fetchContent(mediaSource: MediaSource, options: Options): FetchResult? { + return mediaLoader.loadMediaContent( + source = mediaSource, + ).map { byteArray -> + byteArray.asSourceResult(options) + }.getOrNull() + } + + private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail, options: Options): FetchResult? { + return mediaLoader.loadMediaThumbnail( + source = mediaSource, + width = kind.width, + height = kind.height + ).map { byteArray -> + byteArray.asSourceResult(options) + }.getOrNull() + } + + private fun ByteArray.asSourceResult(options: Options): SourceResult { + val byteBuffer = ByteBuffer.wrap(this) + val bufferedSource = try { + Buffer().apply { write(byteBuffer) } + } finally { + byteBuffer.position(0) + } + return SourceResult( + source = ImageSource(bufferedSource, options.context), + mimeType = null, + dataSource = DataSource.MEMORY + ) + } + + class MediaRequestDataFactory( + private val client: MatrixClient + ) : + Fetcher.Factory<MediaRequestData> { + override fun create( + data: MediaRequestData, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return CoilMediaFetcher( + mediaLoader = client.mediaLoader, + mediaData = data, + options = options + ) + } + } + + class AvatarFactory( + private val client: MatrixClient + ) : + Fetcher.Factory<AvatarData> { + + override fun create( + data: AvatarData, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return CoilMediaFetcher( + mediaLoader = client.mediaLoader, + mediaData = data.toMediaRequestData(), + options = options + ) + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt new file mode 100644 index 0000000000..c5b7f1ed44 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import android.content.Context +import android.os.Build +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Provider + +class LoggedInImageLoaderFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val okHttpClient: Provider<OkHttpClient>, +) : ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader + .Builder(context) + .okHttpClient { okHttpClient.get() } + .components { + // Add gif support + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(AvatarDataKeyer()) + add(MediaRequestDataKeyer()) + add(CoilMediaFetcher.AvatarFactory(matrixClient)) + add(CoilMediaFetcher.MediaRequestDataFactory(matrixClient)) + } + .build() + } +} + +class NotLoggedInImageLoaderFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val okHttpClient: Provider<OkHttpClient>, +) : ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return ImageLoader + .Builder(context) + .okHttpClient { okHttpClient.get() } + .build() + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt new file mode 100644 index 0000000000..f2593766bc --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import io.element.android.libraries.matrix.api.media.MediaSource + +/** + * Can be use with [coil.compose.AsyncImage] to load a [MediaSource]. + * This will go internally through our [CoilMediaFetcher]. + * + * Example of usage: + * AsyncImage( + * model = MediaRequestData(mediaSource, MediaRequestData.Kind.Content), + * contentScale = ContentScale.Fit, + * ) + * + */ +data class MediaRequestData( + val source: MediaSource?, + val kind: Kind +) { + + sealed interface Kind { + object Content : Kind + data class File(val body: String?, val mimeType: String) : Kind + data class Thumbnail(val width: Long, val height: Long) : Kind { + constructor(size: Long) : this(size, size) + } + } +} + diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt new file mode 100644 index 0000000000..0064c1b63b --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.media + +import coil.key.Keyer +import coil.request.Options +import io.element.android.libraries.designsystem.components.avatar.AvatarData + +internal class AvatarDataKeyer : Keyer<AvatarData> { + override fun key(data: AvatarData, options: Options): String? { + return data.toMediaRequestData().toKey() + } +} + +internal class MediaRequestDataKeyer : Keyer<MediaRequestData> { + override fun key(data: MediaRequestData, options: Options): String? { + return data.toKey() + } +} + +private fun MediaRequestData.toKey(): String? { + if (source == null) return null + return "${source.url}_${kind}" +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt new file mode 100644 index 0000000000..813f4744b0 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.model + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.user.MatrixUser + +fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, +) + +fun MatrixUser.getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt new file mode 100644 index 0000000000..7995672e92 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.room + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers + +@Composable +fun MatrixRoom.getRoomMemberAsState(userId: UserId): State<RoomMember?> { + val roomMembersState by membersStateFlow.collectAsState() + return getRoomMemberAsState(roomMembersState = roomMembersState, userId = userId) +} + +@Composable +fun getRoomMemberAsState(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembers) { + derivedStateOf { + roomMembers?.find { + it.userId == userId + } + } + } +} + +@Composable +fun MatrixRoom.getDirectRoomMember(): State<RoomMember?> { + val roomMembersState by membersStateFlow.collectAsState() + return getDirectRoomMember(roomMembersState = roomMembersState) +} + +@Composable +fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State<RoomMember?> { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembersState) { + derivedStateOf { + if (roomMembers == null) { + null + } else if (roomMembers.size == 2 && isDirect && isEncrypted) { + roomMembers.find { it.userId != this.sessionId } + } else { + null + } + } + } +} + diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt new file mode 100644 index 0000000000..005a0ac747 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.matrix.ui.room + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage + +@Composable +fun MatrixRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): State<Boolean> { + return produceState(initialValue = true, key1 = updateKey) { + value = canSendMessage(type).getOrElse { true } + } +} + diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts new file mode 100644 index 0000000000..75afa417f4 --- /dev/null +++ b/libraries/mediapickers/api/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.mediapickers.api" + + dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(libs.inject) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) + } +} diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt new file mode 100644 index 0000000000..9eca6b373a --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers.api + +import androidx.activity.compose.ManagedActivityResultLauncher + +/** + * Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers. + */ +interface PickerLauncher<Input, Output> { + /** Starts the activity result launcher with its default input. */ + fun launch() + + /** Starts the activity result launcher with a [customInput]. */ + fun launch(customInput: Input) +} + +class ComposePickerLauncher<Input, Output>( + private val managedLauncher: ManagedActivityResultLauncher<Input, Output>, + private val defaultRequest: Input, +) : PickerLauncher<Input, Output> { + override fun launch() { + managedLauncher.launch(defaultRequest) + } + + override fun launch(customInput: Input) { + managedLauncher.launch(customInput) + } +} + +/** Needed for screenshot tests. */ +class NoOpPickerLauncher<Input, Output>( + private val onResult: () -> Unit, +) : PickerLauncher<Input, Output> { + override fun launch() = onResult() + override fun launch(customInput: Input) = onResult() +} diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt new file mode 100644 index 0000000000..9becdc8aee --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers.api + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable + +interface PickerProvider { + + @Composable + fun registerGalleryPicker( + onResult: (uri: Uri?, mimeType: String?) -> Unit + ): PickerLauncher<PickVisualMediaRequest, Uri?> + + @Composable + fun registerGalleryImagePicker( + onResult: (Uri?) -> Unit + ): PickerLauncher<PickVisualMediaRequest, Uri?> + + @Composable + fun registerFilePicker( + mimeType: String, + onResult: (Uri?) -> Unit + ): PickerLauncher<String, Uri?> + + @Composable + fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> + + @Composable + fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> +} diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt new file mode 100644 index 0000000000..7c86009d34 --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers.api + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import io.element.android.libraries.core.mimetype.MimeTypes + +sealed interface PickerType<Input, Output> { + fun getContract(): ActivityResultContract<Input, Output> + fun getDefaultRequest(): Input + + object Image : PickerType<PickVisualMediaRequest, Uri?> { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + } + } + + object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + } + + object Camera { + data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> { + override fun getContract() = ActivityResultContracts.TakePicture() + override fun getDefaultRequest(): Uri { + return destUri + } + } + + data class Video(val destUri: Uri) : PickerType<Uri, Boolean> { + override fun getContract() = ActivityResultContracts.CaptureVideo() + override fun getDefaultRequest(): Uri { + return destUri + } + } + } + + data class File(val mimeType: String = MimeTypes.Any) : PickerType<String, Uri?> { + override fun getContract() = ActivityResultContracts.GetContent() + override fun getDefaultRequest(): String { + return mimeType + } + } +} diff --git a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt new file mode 100644 index 0000000000..4f17081f8b --- /dev/null +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers + +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediapickers.api.PickerType +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PickerTypeTests { + + @Test + fun `ImageAndVideo - assert types`() { + val pickerType = PickerType.ImageAndVideo + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.PickVisualMedia::class.java) + assertThat(pickerType.getDefaultRequest().mediaType).isEqualTo(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + + @Test + fun `File - assert types`() { + val pickerType = PickerType.File() + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(MimeTypes.Any) + + val mimeType = MimeTypes.Images + val customPickerType = PickerType.File(mimeType) + assertThat(customPickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java) + assertThat(customPickerType.getDefaultRequest()).isEqualTo(mimeType) + } + + @Test + fun `CameraPhoto - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Photo(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.TakePicture::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + + @Test + fun `CameraVideo - assert types`() { + val uri = Uri.parse("file:///tmp/test") + val pickerType = PickerType.Camera.Video(uri) + assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.CaptureVideo::class.java) + assertThat(pickerType.getDefaultRequest()).isEqualTo(uri) + } + +} diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts new file mode 100644 index 0000000000..856e7765b0 --- /dev/null +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.mediapickers.impl" + + dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(libs.inject) + api(projects.libraries.mediapickers.api) + } +} diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt new file mode 100644 index 0000000000..201fd9b069 --- /dev/null +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers.impl + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.core.content.FileProvider +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.mediapickers.api.ComposePickerLauncher +import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher +import io.element.android.libraries.mediapickers.api.PickerLauncher +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediapickers.api.PickerType +import java.io.File +import java.util.UUID +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProvider { + + @Inject + constructor(): this(false) + + /** + * Remembers and returns a [PickerLauncher] for a certain media/file [type]. + */ + @Composable + internal fun <Input, Output> rememberPickerLauncher( + type: PickerType<Input, Output>, + onResult: (Output) -> Unit, + ): PickerLauncher<Input, Output> { + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { } + } else { + val contract = type.getContract() + val managedLauncher = rememberLauncherForActivityResult(contract = contract, onResult = onResult) + remember(type) { ComposePickerLauncher(managedLauncher, type.getDefaultRequest()) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a gallery picture. + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video. + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerGalleryPicker( + onResult: (uri: Uri?, mimeType: String?) -> Unit + ): PickerLauncher<PickVisualMediaRequest, Uri?> { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null, null) } + } else { + val context = LocalContext.current + rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> + val mimeType = uri?.let { context.contentResolver.getType(it) } + onResult(uri, mimeType) + } + } + } + + /** + * Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default). + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerFilePicker( + mimeType: String, + onResult: (Uri?) -> Unit, + ): PickerLauncher<String, Uri?> { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) } + } + } + + /** + * Remembers and returns a [PickerLauncher] for taking a photo with a camera app. + * @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + } + } + } + + /** + * Remembers and returns a [PickerLauncher] for recording a video with a camera app. + * @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + val context = LocalContext.current + val tmpFile = remember { getTemporaryFile(context) } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + } + } + } + + private fun getTemporaryFile( + context: Context, + baseFolder: File = context.cacheDir, + filename: String = UUID.randomUUID().toString(), + ): File { + return File(baseFolder, filename) + } + + private fun getTemporaryUri( + context: Context, + file: File, + ): Uri { + val authority = "${context.packageName}.fileprovider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts new file mode 100644 index 0000000000..87bde9b6e7 --- /dev/null +++ b/libraries/mediapickers/test/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.mediapickers.test" + + dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(libs.inject) + api(projects.libraries.mediapickers.api) + } +} diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt new file mode 100644 index 0000000000..e243a22138 --- /dev/null +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediapickers.test + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher +import io.element.android.libraries.mediapickers.api.PickerLauncher +import io.element.android.libraries.mediapickers.api.PickerProvider + +class FakePickerProvider : PickerProvider { + private var mimeType = MimeTypes.Any + private var result: Uri? = null + + @Composable + override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> { + return NoOpPickerLauncher { onResult(result, mimeType) } + } + + @Composable + override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> { + return NoOpPickerLauncher { onResult(result) } + } + + fun givenResult(value: Uri?) { + this.result = value + } + + fun givenMimeType(mimeType: String) { + this.mimeType = mimeType + } +} diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts new file mode 100644 index 0000000000..111abc2bcc --- /dev/null +++ b/libraries/mediaupload/api/build.gradle.kts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.mediaupload.api" + + anvil { + generateDaggerFactories.set(true) + } + + dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.libraries.matrix.api) + implementation(libs.inject) + implementation(libs.coroutines.core) + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt new file mode 100644 index 0000000000..31c6a813ff --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload.api + +import android.net.Uri + +interface MediaPreProcessor { + /** + * Given a [uri] and [mimeType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata. + * If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes. + * @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload. + */ + suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean = false, + compressIfPossible: Boolean + ): Result<MediaUploadInfo> + + data class Failure(override val cause: Throwable?) : RuntimeException(cause) +} + diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt new file mode 100644 index 0000000000..cfa59d65d3 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload.api + +import android.net.Uri +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.room.MatrixRoom +import javax.inject.Inject + +class MediaSender @Inject constructor( + private val preProcessor: MediaPreProcessor, + private val room: MatrixRoom, +) { + + suspend fun sendMedia( + uri: Uri, + mimeType: String, + compressIfPossible: Boolean, + progressCallback: ProgressCallback? = null + ): Result<Unit> { + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = true, + compressIfPossible = compressIfPossible + ) + .flatMap { info -> + room.sendMedia(info, progressCallback) + } + } + + private suspend fun MatrixRoom.sendMedia( + uploadInfo: MediaUploadInfo, + progressCallback: ProgressCallback? + ): Result<Unit> { + return when (uploadInfo) { + is MediaUploadInfo.Image -> { + sendImage( + file = uploadInfo.file, + thumbnailFile = uploadInfo.thumbnailFile, + imageInfo = uploadInfo.imageInfo, + progressCallback = progressCallback + ) + } + + is MediaUploadInfo.Video -> { + sendVideo( + file = uploadInfo.file, + thumbnailFile = uploadInfo.thumbnailFile, + videoInfo = uploadInfo.videoInfo, + progressCallback = progressCallback + ) + } + is MediaUploadInfo.Audio -> { + sendAudio( + file = uploadInfo.file, + audioInfo = uploadInfo.audioInfo, + progressCallback = progressCallback + ) + } + + is MediaUploadInfo.AnyFile -> { + sendFile( + file = uploadInfo.file, + fileInfo = uploadInfo.fileInfo, + progressCallback = progressCallback + ) + } + else -> Result.failure(IllegalStateException("Unexpected MediaUploadInfo format: $uploadInfo")) + } + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt new file mode 100644 index 0000000000..51f6372b23 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload.api + +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import java.io.File + +sealed interface MediaUploadInfo { + + val file: File + + data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo + data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo + data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo + data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo +} diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts new file mode 100644 index 0000000000..a23ab14b74 --- /dev/null +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.mediaupload.impl" + + anvil { + generateDaggerFactories.set(true) + } + + dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + + api(projects.libraries.mediaupload.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(libs.inject) + implementation(libs.androidx.exifinterface) + implementation(libs.coroutines.core) + implementation(libs.otaliastudios.transcoder) + implementation(libs.vanniktech.blurhash) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt new file mode 100644 index 0000000000..8ff40fae39 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessor.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload + +import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.file.getFileName +import io.element.android.libraries.androidutils.file.safeRenameTo +import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.time.Duration +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidMediaPreProcessor @Inject constructor( + @ApplicationContext private val context: Context, + private val thumbnailFactory: ThumbnailFactory, + private val imageCompressor: ImageCompressor, + private val videoCompressor: VideoCompressor, + private val coroutineDispatchers: CoroutineDispatchers, +) : MediaPreProcessor { + companion object { + /** + * Used for calculating `inSampleSize` for bitmaps. + * + * *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height + * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is). + */ + private const val IMAGE_SCALE_REF_SIZE = 640 + } + + private val contentResolver = context.contentResolver + + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + compressIfPossible: Boolean, + ): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) { + runCatching { + val result = when { + mimeType.isMimeTypeImage() -> processImage(uri, mimeType, compressIfPossible && mimeType != MimeTypes.Gif) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, compressIfPossible) + mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) + else -> processFile(uri, mimeType) + } + if (deleteOriginal) { + tryOrNull { + contentResolver.delete(uri, null, null) + } + } + result.postProcess(uri) + } + }.mapFailure { MediaPreProcessor.Failure(it) } + + private suspend fun processFile(uri: Uri, mimeType: String): MediaUploadInfo { + val file = copyToTmpFile(uri) + val info = FileInfo( + mimetype = mimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailSource = null, + ) + return MediaUploadInfo.AnyFile(file, info) + } + + private fun MediaUploadInfo.postProcess(uri: Uri): MediaUploadInfo { + val name = context.getFileName(uri) ?: return this + val renamedFile = File(context.cacheDir, name).also { + file.safeRenameTo(it) + } + return when (this) { + is MediaUploadInfo.AnyFile -> copy(file = renamedFile) + is MediaUploadInfo.Audio -> copy(file = renamedFile) + is MediaUploadInfo.Image -> copy(file = renamedFile) + is MediaUploadInfo.Video -> copy(file = renamedFile) + } + } + + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { + + suspend fun processImageWithCompression(): MediaUploadInfo { + val compressionResult = contentResolver.openInputStream(uri).use { input -> + imageCompressor.compressToTmpFile( + inputStream = requireNotNull(input), + resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), + ).getOrThrow() + } + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file) + val imageInfo = compressionResult.toImageInfo( + mimeType = mimeType, + thumbnailResult = thumbnailResult + ) + removeSensitiveImageMetadata(compressionResult.file) + return MediaUploadInfo.Image( + file = compressionResult.file, + imageInfo = imageInfo, + thumbnailFile = thumbnailResult.file + ) + } + + suspend fun processImageWithoutCompression(): MediaUploadInfo { + val file = copyToTmpFile(uri) + val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(file) + val imageInfo = contentResolver.openInputStream(uri).use { input -> + val bitmap = BitmapFactory.decodeStream(input, null, null)!! + ImageInfo( + width = bitmap.width.toLong(), + height = bitmap.height.toLong(), + mimetype = mimeType, + size = file.length(), + thumbnailInfo = thumbnailResult.info, + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, + ) + } + removeSensitiveImageMetadata(file) + return MediaUploadInfo.Image( + file = file, + imageInfo = imageInfo, + thumbnailFile = thumbnailResult.file + ) + } + + return if (shouldBeCompressed) { + processImageWithCompression() + } else { + processImageWithoutCompression() + } + } + + private suspend fun processVideo(uri: Uri, mimeType: String?, shouldBeCompressed: Boolean): MediaUploadInfo { + val resultFile = if (shouldBeCompressed) { + videoCompressor.compress(uri) + .onEach { + // TODO handle progress + } + .filterIsInstance<VideoTranscodingEvent.Completed>() + .first() + .file + } else { + copyToTmpFile(uri) + } + val thumbnailInfo = thumbnailFactory.createVideoThumbnail(resultFile) + val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo) + return MediaUploadInfo.Video( + file = resultFile, + videoInfo = videoInfo, + thumbnailFile = thumbnailInfo.file + ) + } + + private suspend fun processAudio(uri: Uri, mimeType: String?): MediaUploadInfo { + val file = copyToTmpFile(uri) + return MediaMetadataRetriever().runAndRelease { + setDataSource(context, Uri.fromFile(file)) + val info = AudioInfo( + duration = extractDuration(), + size = file.length(), + mimetype = mimeType, + ) + + MediaUploadInfo.Audio(file, info) + } + } + + private fun removeSensitiveImageMetadata(file: File) { + // Remove GPS info, user comments and subject location tags + val exifInterface = ExifInterface(file) + // See ExifInterface.TAG_GPS_INFO_IFD_POINTER + exifInterface.setAttribute("GPSInfoIFDPointer", null) + exifInterface.setAttribute(ExifInterface.TAG_USER_COMMENT, null) + exifInterface.setAttribute(ExifInterface.TAG_SUBJECT_LOCATION, null) + tryOrNull { exifInterface.saveAttributes() } + } + + private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { + return withContext(coroutineDispatchers.io) { + tryOrNull { + val tmpFile = context.createTmpFile() + tmpFile.outputStream().use { inputStream.copyTo(it) } + tmpFile + } + } + } + + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult): VideoInfo = + MediaMetadataRetriever().runAndRelease { + setDataSource(context, Uri.fromFile(file)) + VideoInfo( + duration = extractDuration(), + width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, + height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, + mimetype = mimeType, + size = file.length(), + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, + ) + } + + private suspend fun copyToTmpFile(uri: Uri): File { + return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) } + ?: error("Could not copy the contents of $uri to a temporary file") + } +} + +fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult) = ImageInfo( + width = width.toLong(), + height = height.toLong(), + mimetype = mimeType, + size = size, + thumbnailInfo = thumbnailResult.info, + // Will be computed by the rust sdk + thumbnailSource = null, + blurhash = thumbnailResult.blurhash, +) + +private fun MediaMetadataRetriever.extractDuration(): Duration { + val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + return Duration.ofMillis(durationInMs) +} + diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt new file mode 100644 index 0000000000..2b8669fe42 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.vanniktech.blurhash.BlurHash +import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize +import io.element.android.libraries.androidutils.bitmap.resizeToMax +import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import javax.inject.Inject + +class ImageCompressor @Inject constructor( + @ApplicationContext private val context: Context, +) { + + /** + * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a + * temporary file using the passed [format] and [desiredQuality]. + * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata. + */ + suspend fun compressToTmpFile( + inputStream: InputStream, + resizeMode: ResizeMode, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + desiredQuality: Int = 80, + ): Result<ImageCompressionResult> = withContext(Dispatchers.IO) { + runCatching { + val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow() + // Encode bitmap to the destination temporary file + val tmpFile = context.createTmpFile(extension = "jpeg") + tmpFile.outputStream().use { + compressedBitmap.compress(format, desiredQuality, it) + } + ImageCompressionResult( + file = tmpFile, + width = compressedBitmap.width, + height = compressedBitmap.height, + size = tmpFile.length() + ) + } + } + + /** + * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode]. + * @return a [Result] containing the resulting [Bitmap]. + */ + fun compressToBitmap( + inputStream: InputStream, + resizeMode: ResizeMode, + ): Result<Bitmap> = runCatching { + BufferedInputStream(inputStream).use { input -> + val options = BitmapFactory.Options() + calculateDecodingScale(input, resizeMode, options) + val decodedBitmap = BitmapFactory.decodeStream(input, null, options) + ?: error("Decoding Bitmap from InputStream failed") + val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(input).getOrThrow() + if (resizeMode is ResizeMode.Strict) { + rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight) + } else { + rotatedBitmap + } + } + } + + private fun calculateDecodingScale( + inputStream: BufferedInputStream, + resizeMode: ResizeMode, + options: BitmapFactory.Options + ) { + val (width, height) = when (resizeMode) { + is ResizeMode.Approximate -> resizeMode.desiredWidth to resizeMode.desiredHeight + is ResizeMode.Strict -> (resizeMode.maxWidth / 2) to (resizeMode.maxHeight / 2) + is ResizeMode.None -> return + } + // Read bounds only + inputStream.mark(inputStream.available()) + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, options) + // Set sample size based on the outWidth and outHeight + options.inSampleSize = options.calculateInSampleSize(width, height) + // Now read the actual image and rotate it to match its metadata + inputStream.reset() + options.inJustDecodeBounds = false + } +} + +data class ImageCompressionResult( + val file: File, + val width: Int, + val height: Int, + val size: Long, +) + +sealed interface ResizeMode { + object None : ResizeMode + data class Approximate(val desiredWidth: Int, val desiredHeight: Int) : ResizeMode + data class Strict(val maxWidth: Int, val maxHeight: Int) : ResizeMode +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt new file mode 100644 index 0000000000..a9ed6319cb --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.media.ThumbnailUtils +import android.os.Build +import android.os.CancellationSignal +import android.provider.MediaStore +import android.util.Size +import androidx.core.net.toUri +import com.vanniktech.blurhash.BlurHash +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import javax.inject.Inject +import kotlin.coroutines.resume + +/** + * Max width of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ +private const val THUMB_MAX_WIDTH = 800 + +/** + * Max height of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ +private const val THUMB_MAX_HEIGHT = 600 + +/** + * Frame of the video to be used for generating a thumbnail. + */ +private const val VIDEO_THUMB_FRAME = 0L + +class ThumbnailFactory @Inject constructor( + @ApplicationContext private val context: Context, +) { + + @SuppressLint("NewApi") + suspend fun createImageThumbnail(file: File): ThumbnailResult { + return createThumbnail { cancellationSignal -> + // This API works correctly with GIF + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ThumbnailUtils.createImageThumbnail( + file, + Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + cancellationSignal + ) + } else { + ThumbnailUtils.createImageThumbnail( + file.path, + MediaStore.Images.Thumbnails.MINI_KIND, + ) + } + } + } + + suspend fun createVideoThumbnail(file: File): ThumbnailResult { + return createThumbnail { + MediaMetadataRetriever().runAndRelease { + setDataSource(context, file.toUri()) + getFrameAtTime(VIDEO_THUMB_FRAME) + } + } + } + + private suspend fun createThumbnail(bitmapFactory: (CancellationSignal) -> Bitmap?): ThumbnailResult = suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { + cancellationSignal.cancel() + } + val bitmapThumbnail: Bitmap? = bitmapFactory(cancellationSignal) + val thumbnailFile = context.createTmpFile(extension = "jpeg") + thumbnailFile.outputStream().use { outputStream -> + bitmapThumbnail?.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + } + val blurhash = bitmapThumbnail?.let { + BlurHash.encode(it, 3, 3) + } + val thumbnailResult = ThumbnailResult( + file = thumbnailFile, + info = ThumbnailInfo( + height = bitmapThumbnail?.height?.toLong(), + width = bitmapThumbnail?.width?.toLong(), + mimetype = MimeTypes.Jpeg, + size = thumbnailFile.length() + ), + blurhash = blurhash + ) + bitmapThumbnail?.recycle() + continuation.resume(thumbnailResult) + + } +} + +data class ThumbnailResult( + val file: File, + val info: ThumbnailInfo, + val blurhash: String?, +) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt new file mode 100644 index 0000000000..e7e294cd7c --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload + +import android.content.Context +import android.net.Uri +import com.otaliastudios.transcoder.Transcoder +import com.otaliastudios.transcoder.TranscoderListener +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import java.io.File +import javax.inject.Inject + +class VideoCompressor @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun compress(uri: Uri) = callbackFlow { + val tmpFile = context.createTmpFile(extension = "mp4") + val future = Transcoder.into(tmpFile.path) + .addDataSource(context, uri) + .setListener(object : TranscoderListener { + override fun onTranscodeProgress(progress: Double) { + trySend(VideoTranscodingEvent.Progress(progress.toFloat())) + } + + override fun onTranscodeCompleted(successCode: Int) { + trySend(VideoTranscodingEvent.Completed(tmpFile)) + close() + } + + override fun onTranscodeCanceled() { + tmpFile.safeDelete() + close() + } + + override fun onTranscodeFailed(exception: Throwable) { + tmpFile.safeDelete() + close(exception) + } + }) + .transcode() + + awaitClose { + if (!future.isDone) { + future.cancel(true) + } + } + } +} + +sealed interface VideoTranscodingEvent { + data class Progress(val value: Float) : VideoTranscodingEvent + data class Completed(val file: File) : VideoTranscodingEvent +} diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts new file mode 100644 index 0000000000..956afffbe0 --- /dev/null +++ b/libraries/mediaupload/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaupload.test" +} + +dependencies { + api(projects.libraries.mediaupload.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt new file mode 100644 index 0000000000..0cc7803578 --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.mediaupload.test + +import android.net.Uri +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.tests.testutils.simulateLongTask +import java.io.File + +class FakeMediaPreProcessor : MediaPreProcessor { + + private var result: Result<MediaUploadInfo> = Result.success( + MediaUploadInfo.AnyFile( + File("test"), + FileInfo( + mimetype = "*/*", + size = 999L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + compressIfPossible: Boolean + ): Result<MediaUploadInfo> = simulateLongTask { + result + } + + fun givenResult(value: Result<MediaUploadInfo>) { + this.result = value + } +} diff --git a/libraries/network/build.gradle.kts b/libraries/network/build.gradle.kts new file mode 100644 index 0000000000..f2c62188f9 --- /dev/null +++ b/libraries/network/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.network" + + buildTypes { + release { + isMinifyEnabled = true + consumerProguardFiles("consumer-rules.pro") + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(platform(libs.network.okhttp.bom)) + implementation(libs.network.okhttp) + implementation(libs.network.okhttp.logging) + implementation(libs.network.retrofit) + implementation(libs.network.retrofit.converter.serialization) + implementation(libs.serialization.json) +} diff --git a/libraries/network/consumer-rules.pro b/libraries/network/consumer-rules.pro new file mode 100644 index 0000000000..1026dca840 --- /dev/null +++ b/libraries/network/consumer-rules.pro @@ -0,0 +1,9 @@ +# From https://github.com/square/retrofit/issues/3751#issuecomment-1192043644 +# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). +-keep,allowobfuscation,allowshrinking interface retrofit2.Call +-keep,allowobfuscation,allowshrinking class retrofit2.Response + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt new file mode 100644 index 0000000000..c36a0411e5 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger +import io.element.android.libraries.network.interceptors.UserAgentInterceptor +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit + +@Module +@ContributesTo(AppScope::class) +object NetworkModule { + @Provides + @SingleIn(AppScope::class) + fun providesOkHttpClient( + buildMeta: BuildMeta, + userAgentInterceptor: UserAgentInterceptor, + ): OkHttpClient = OkHttpClient.Builder().apply { + connectTimeout(30, TimeUnit.SECONDS) + readTimeout(60, TimeUnit.SECONDS) + writeTimeout(60, TimeUnit.SECONDS) + addInterceptor(userAgentInterceptor) + if (buildMeta.isDebuggable) addInterceptor(providesHttpLoggingInterceptor()) + }.build() + + @Provides + @SingleIn(AppScope::class) + fun providesJson(): Json = Json { + ignoreUnknownKeys = true + } +} + +private fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor { + val loggingLevel = HttpLoggingInterceptor.Level.BODY + val logger = FormattedJsonHttpLogger(loggingLevel) + val interceptor = HttpLoggingInterceptor(logger) + interceptor.level = loggingLevel + return interceptor +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt new file mode 100644 index 0000000000..dc8b664764 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import io.element.android.libraries.core.uri.ensureTrailingSlash +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Provider + +class RetrofitFactory @Inject constructor( + private val okHttpClient: Provider<OkHttpClient>, + private val json: Provider<Json>, +) { + fun create(baseUrl: String): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl.ensureTrailingSlash()) + .addConverterFactory(json.get().asConverterFactory("application/json".toMediaType())) + .callFactory { request -> okHttpClient.get().newCall(request) } + .build() +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/headers/HttpHeaders.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/headers/HttpHeaders.kt new file mode 100644 index 0000000000..315835d584 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/headers/HttpHeaders.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.headers + +internal object HttpHeaders { + const val Authorization = "Authorization" + const val UserAgent = "User-Agent" +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/FormattedJsonHttpLogger.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/FormattedJsonHttpLogger.kt new file mode 100644 index 0000000000..d6c3144c5c --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/FormattedJsonHttpLogger.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.interceptors + +import okhttp3.logging.HttpLoggingInterceptor +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber + +internal class FormattedJsonHttpLogger( + private val level: HttpLoggingInterceptor.Level +) : HttpLoggingInterceptor.Logger { + + companion object { + private const val INDENT_SPACE = 2 + } + + /** + * Log the message and try to log it again as a JSON formatted string. + * Note: it can consume a lot of memory but it is only in DEBUG mode. + * + * @param message + */ + @Synchronized + override fun log(message: String) { + Timber.v(message) + + // Try to log formatted Json only if there is a chance that [message] contains Json. + // It can be only the case if we log the bodies of Http requests. + if (level != HttpLoggingInterceptor.Level.BODY) return + + if (message.startsWith("{")) { + // JSON Detected + try { + val o = JSONObject(message) + logJson(o.toString(INDENT_SPACE)) + } catch (e: JSONException) { + // Finally this is not a JSON string... + Timber.e(e) + } + } else if (message.startsWith("[")) { + // JSON Array detected + try { + val o = JSONArray(message) + logJson(o.toString(INDENT_SPACE)) + } catch (e: JSONException) { + // Finally not JSON... + Timber.e(e) + } + } + // Else not a json string to log + } + + private fun logJson(formattedJson: String) { + formattedJson + .lines() + .dropLastWhile { it.isEmpty() } + .forEach { Timber.v(it) } + } +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/UserAgentInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/UserAgentInterceptor.kt new file mode 100644 index 0000000000..4c3f0419d8 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/UserAgentInterceptor.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.interceptors + +import io.element.android.libraries.network.headers.HttpHeaders +import io.element.android.libraries.network.useragent.UserAgentProvider +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +class UserAgentInterceptor @Inject constructor( + private val userAgentProvider: UserAgentProvider, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request() + .newBuilder() + .header(HttpHeaders.UserAgent, userAgentProvider.provide()) + .build() + return chain.proceed(newRequest) + } +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt new file mode 100644 index 0000000000..7b0b5bedcc --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.useragent + +import android.os.Build +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultUserAgentProvider @Inject constructor( + private val buildMeta: BuildMeta, +) : UserAgentProvider { + private val userAgent: String by lazy { buildUserAgent() } + + override fun provide(): String = userAgent + + /** + * Create an user agent with the application version. + * Ex: Element X/1.5.0 (Xiaomi Mi 9T; Android 11; RKQ1.200826.002; Sdk 0.1.0) + */ + private fun buildUserAgent(): String { + val appName = buildMeta.applicationName + val appVersion = buildMeta.versionName + val deviceManufacturer = Build.MANUFACTURER + val deviceModel = Build.MODEL + val androidVersion = Build.VERSION.RELEASE + val deviceBuildId = Build.DISPLAY + val matrixSdkVersion = "TODO" + + return buildString { + append(appName) + append("/") + append(appVersion) + append(" (") + append(deviceManufacturer) + append(" ") + append(deviceModel) + append("; ") + append("Android ") + append(androidVersion) + append("; ") + append(deviceBuildId) + append("; ") + append("Sdk ") + append(matrixSdkVersion) + append(")") + } + } +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/SimpleUserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/SimpleUserAgentProvider.kt new file mode 100644 index 0000000000..319eeeb65b --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/SimpleUserAgentProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.useragent + +class SimpleUserAgentProvider( + private val userAgent: String = "User agent" +) : UserAgentProvider { + override fun provide(): String = userAgent +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/UserAgentProvider.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/UserAgentProvider.kt new file mode 100644 index 0000000000..0266b518cb --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/UserAgentProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.network.useragent + +interface UserAgentProvider { + fun provide(): String +} diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts new file mode 100644 index 0000000000..32d3776419 --- /dev/null +++ b/libraries/permissions/api/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.permissions.api" +} + +dependencies { + implementation(projects.libraries.architecture) + + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt new file mode 100644 index 0000000000..a0b2411459 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +sealed interface PermissionsEvents { + object OpenSystemDialog : PermissionsEvents + object CloseDialog : PermissionsEvents +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt new file mode 100644 index 0000000000..c4ab065ca0 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +import io.element.android.libraries.architecture.Presenter + +interface PermissionsPresenter : Presenter<PermissionsState> { + + interface Factory { + fun create(permission: String): PermissionsPresenter + } +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt new file mode 100644 index 0000000000..975674820b --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +data class PermissionsState( + // For instance Manifest.permission.POST_NOTIFICATIONS + val permission: String, + val permissionGranted: Boolean, + val shouldShowRationale: Boolean, + val showDialog: Boolean, + val permissionAlreadyAsked: Boolean, + // If true, there is no need to ask again, the system dialog will not be displayed + val permissionAlreadyDenied: Boolean, + val eventSink: (PermissionsEvents) -> Unit +) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt new file mode 100644 index 0000000000..30f19aa31c --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun PermissionsView( + state: PermissionsState, + modifier: Modifier = Modifier, + openSystemSettings: () -> Unit = {}, +) { + if (state.showDialog.not()) return + + when { + state.permissionGranted -> { + // Notification Granted, nothing to do + } + state.permissionAlreadyDenied -> { + // In this case, tell the user to go to the settings + ConfirmationDialog( + modifier = modifier, + title = "System", + content = "In order to let the application display notification, please grant the permission to the system settings", + submitText = "Open settings", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.CloseDialog) + openSystemSettings() + }, + onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + ) + } + else -> { + val textToShow = if (state.shouldShowRationale) { + // TODO Move to state + // If the user has denied the permission but the rationale can be shown, + // then gently explain why the app requires this permission + // permissions_rationale_msg_notification + "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." + } else { + // TODO Move to state + // If it's the first time the user lands on this feature, or the user + // doesn't want to be asked again for this permission, explain that the + // permission is required + "To be able to receive notifications, please grant the permission." + } + ConfirmationDialog( + modifier = modifier, + title = "Notifications", + content = textToShow, + submitText = "Request permission", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + }, + onCancelClicked = { + state.eventSink.invoke(PermissionsEvents.CloseDialog) + }, + onDismiss = {} + ) + } + } +} + +@Preview +@Composable +fun PermissionsViewLightPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun PermissionsViewDarkPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: PermissionsState) { + PermissionsView( + state = state, + ) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt new file mode 100644 index 0000000000..e93b74d934 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> { + override val values: Sequence<PermissionsState> + get() = sequenceOf( + aPermissionsState(), + aPermissionsState().copy(shouldShowRationale = true), + aPermissionsState().copy(permissionAlreadyDenied = true), + ) +} + +fun aPermissionsState() = PermissionsState( + permission = Manifest.permission.INTERNET, + permissionGranted = false, + shouldShowRationale = false, + showDialog = true, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = {} +) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt new file mode 100644 index 0000000000..b35ce36380 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.api + +fun createDummyPostNotificationPermissionsState() = PermissionsState( + permission = "Manifest.permission.POST_NOTIFICATIONS", + permissionGranted = true, + shouldShowRationale = false, + showDialog = false, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = { } +) diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts new file mode 100644 index 0000000000..31a4e4bbb1 --- /dev/null +++ b/libraries/permissions/impl/build.gradle.kts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.permissions.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(libs.accompanist.permission) + implementation(libs.androidx.datastore.preferences) + + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.libraries.permissions.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + androidTestImplementation(libs.test.junitext) + + ksp(libs.showkase.processor) +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt new file mode 100644 index 0000000000..15acd868f2 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.rememberPermissionState +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +interface PermissionStateProvider { + @Composable + fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState +} + +@ContributesBinding(AppScope::class) +class AccompanistPermissionStateProvider @Inject constructor() : PermissionStateProvider { + @Composable + override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState { + return rememberPermissionState( + permission = permission, + onPermissionResult = onPermissionResult + ) + } +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt new file mode 100644 index 0000000000..50012f01b7 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.impl + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.shouldShowRationale +import com.squareup.anvil.annotations.ContributesBinding +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("DefaultPermissionsPresenter") + +class DefaultPermissionsPresenter @AssistedInject constructor( + @Assisted val permission: String, + private val permissionsStore: PermissionsStore, + private val permissionStateProvider: PermissionStateProvider, +) : PermissionsPresenter { + + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : PermissionsPresenter.Factory { + override fun create(permission: String): DefaultPermissionsPresenter + } + + @OptIn(ExperimentalPermissionsApi::class) + @SuppressLint("InlinedApi") + @Composable + override fun present(): PermissionsState { + val localCoroutineScope = rememberCoroutineScope() + + // To reset the store: ResetStore() + + val isAlreadyDenied: Boolean by permissionsStore + .isPermissionDenied(permission) + .collectAsState(initial = false) + + val isAlreadyAsked: Boolean by permissionsStore + .isPermissionAsked(permission) + .collectAsState(initial = false) + + var permissionState: PermissionState? = null + + fun onPermissionResult(result: Boolean) { + Timber.tag(loggerTag.value).d("onPermissionResult: $result") + localCoroutineScope.launch { + permissionsStore.setPermissionAsked(permission, true) + } + + if (!result) { + // Should show rational true -> denied. + if (permissionState?.status?.shouldShowRationale == true) { + Timber.tag(loggerTag.value).d("onPermissionResult: setPermissionDenied to true") + localCoroutineScope.launch { + permissionsStore.setPermissionDenied(permission, true) + } + } + } + } + + permissionState = permissionStateProvider.provide( + permission = permission, + onPermissionResult = ::onPermissionResult + ) + + LaunchedEffect(this) { + if (permissionState.status.isGranted) { + // User may have granted permission from the settings, to reset the store regarding this permission + permissionsStore.resetPermission(permission) + } + } + + val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } + + fun handleEvents(event: PermissionsEvents) { + when (event) { + PermissionsEvents.CloseDialog -> { + showDialog.value = false + } + PermissionsEvents.OpenSystemDialog -> { + permissionState.launchPermissionRequest() + showDialog.value = false + } + } + } + + return PermissionsState( + permission = permissionState.permission, + permissionGranted = permissionState.status.isGranted, + shouldShowRationale = permissionState.status.shouldShowRationale, + showDialog = showDialog.value, + permissionAlreadyAsked = isAlreadyAsked, + permissionAlreadyDenied = isAlreadyDenied, + eventSink = ::handleEvents + ).also { + Timber.tag(loggerTag.value).d("New state: $it") + } + } + + /* + @Composable + private fun ResetStore() { + LaunchedEffect(this@DefaultPermissionsPresenter) { + launch { + permissionsStore.resetStore() + } + } + } + */ +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt new file mode 100644 index 0000000000..9ee29b7a61 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "permissions_store") + +@ContributesBinding(AppScope::class) +class DefaultPermissionsStore @Inject constructor( + @ApplicationContext context: Context, +) : PermissionsStore { + private val store = context.dataStore + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + store.edit { prefs -> + prefs[getDeniedPreferenceKey(permission)] = value + } + } + + override fun isPermissionDenied(permission: String): Flow<Boolean> { + return store.data.map { + it[getDeniedPreferenceKey(permission)].orFalse() + } + } + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + store.edit { prefs -> + prefs[getAskedPreferenceKey(permission)] = value + } + } + + override fun isPermissionAsked(permission: String): Flow<Boolean> { + return store.data.map { + it[getAskedPreferenceKey(permission)].orFalse() + } + } + + override suspend fun resetPermission(permission: String) { + setPermissionAsked(permission, false) + setPermissionDenied(permission, false) + } + + override suspend fun resetStore() { + store.edit { it.clear() } + } + + private fun getDeniedPreferenceKey(permission: String) = booleanPreferencesKey("${permission}_denied") + private fun getAskedPreferenceKey(permission: String) = booleanPreferencesKey("${permission}_asked") +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt new file mode 100644 index 0000000000..25b41e2a71 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.impl + +import kotlinx.coroutines.flow.Flow + +interface PermissionsStore { + suspend fun setPermissionDenied(permission: String, value: Boolean) + fun isPermissionDenied(permission: String): Flow<Boolean> + + suspend fun setPermissionAsked(permission: String, value: Boolean) + fun isPermissionAsked(permission: String): Flow<Boolean> + + suspend fun resetPermission(permission: String) + + // To debug + suspend fun resetStore() +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt new file mode 100644 index 0000000000..24b174426c --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.permissions.api.PermissionsEvents +import kotlinx.coroutines.test.runTest +import org.junit.Test + +const val A_PERMISSION = "A_PERMISSION" + +class DefaultPermissionsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permission).isEqualTo(A_PERMISSION) + assertThat(initialState.permissionGranted).isTrue() + assertThat(initialState.shouldShowRationale).isFalse() + assertThat(initialState.permissionAlreadyAsked).isFalse() + assertThat(initialState.permissionAlreadyDenied).isFalse() + assertThat(initialState.showDialog).isFalse() + } + } + + @Test + fun `present - user closes dialog`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.CloseDialog) + assertThat(awaitItem().showDialog).isFalse() + } + } + + @Test + fun `present - user does not grant permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User does not grant permission + permissionStateProvider.userGiveAnswer(answer = false, firstTime = true) + skipItems(1) + val state = awaitItem() + assertThat(state.permissionGranted).isFalse() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isFalse() + assertThat(state.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user does not grant permission second time`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User does not grant permission + permissionStateProvider.userGiveAnswer(answer = false, firstTime = false) + skipItems(2) + val state = awaitItem() + assertThat(state.permissionGranted).isFalse() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isTrue() + assertThat(state.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user does not grant permission third time`() = runTest { + val permissionsStore = InMemoryPermissionsStore(permissionDenied = true, permissionAsked = true) + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + assertThat(initialState.permissionGranted).isFalse() + assertThat(initialState.permissionAlreadyDenied).isTrue() + assertThat(initialState.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user grants permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false)) + val permissionStateProvider = FakePermissionStateProvider(permissionState) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isTrue() + initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + assertThat(awaitItem().showDialog).isFalse() + // User grants permission + permissionStateProvider.userGiveAnswer(answer = true, firstTime = true) + skipItems(1) + val state = awaitItem() + assertThat(state.permissionGranted).isTrue() + assertThat(state.showDialog).isFalse() + assertThat(state.permissionAlreadyDenied).isFalse() + assertThat(state.permissionAlreadyAsked).isTrue() + } + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt new file mode 100644 index 0000000000..c204ff5fc6 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.PermissionStatus + +class FakePermissionStateProvider constructor( + private val permissionState: FakePermissionState +) : PermissionStateProvider { + private lateinit var onPermissionResult: (Boolean) -> Unit + + @OptIn(ExperimentalPermissionsApi::class) + @Composable + override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState { + this.onPermissionResult = onPermissionResult + return permissionState + } + + fun userGiveAnswer(answer: Boolean, firstTime: Boolean) { + onPermissionResult.invoke(answer) + permissionState.givenPermissionStatus(answer, firstTime) + } +} + +@Stable +class FakePermissionState( + override val permission: String, + initialStatus: PermissionStatus, +) : PermissionState { + + override var status: PermissionStatus by mutableStateOf(initialStatus) + + var launchPermissionRequestCalled = false + private set + + override fun launchPermissionRequest() { + launchPermissionRequestCalled = true + } + + fun givenPermissionStatus(hasPermission: Boolean, shouldShowRationale: Boolean) { + status = if (hasPermission) PermissionStatus.Granted else PermissionStatus.Denied(shouldShowRationale) + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt new file mode 100644 index 0000000000..3f5d925ccd --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemoryPermissionsStore( + permissionDenied: Boolean = false, + permissionAsked: Boolean = false, +) : PermissionsStore { + private val permissionDeniedFlow = MutableStateFlow(permissionDenied) + private val permissionAskedFlow = MutableStateFlow(permissionAsked) + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + permissionDeniedFlow.value = value + } + + override fun isPermissionDenied(permission: String): Flow<Boolean> = permissionDeniedFlow + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + permissionAskedFlow.value = value + } + + override fun isPermissionAsked(permission: String): Flow<Boolean> = permissionAskedFlow + + override suspend fun resetPermission(permission: String) { + setPermissionAsked(permission, false) + setPermissionDenied(permission, false) + } + + override suspend fun resetStore() { + } +} diff --git a/libraries/permissions/noop/build.gradle.kts b/libraries/permissions/noop/build.gradle.kts new file mode 100644 index 0000000000..e4b8963c89 --- /dev/null +++ b/libraries/permissions/noop/build.gradle.kts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.noop" +} + +dependencies { + implementation(projects.libraries.architecture) + api(projects.libraries.permissions.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) +} diff --git a/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt new file mode 100644 index 0000000000..653fe49268 --- /dev/null +++ b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.noop + +import androidx.compose.runtime.Composable +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.PermissionsState + +class NoopPermissionsPresenter : PermissionsPresenter { + + @Composable + override fun present(): PermissionsState { + return PermissionsState( + permission = "", + permissionGranted = false, + shouldShowRationale = false, + showDialog = false, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = {}, + ) + } +} diff --git a/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt new file mode 100644 index 0000000000..9992480436 --- /dev/null +++ b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.permissions.noop + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NoopPermissionsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = NoopPermissionsPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permission).isEmpty() + assertThat(initialState.permissionGranted).isFalse() + assertThat(initialState.shouldShowRationale).isFalse() + assertThat(initialState.permissionAlreadyAsked).isFalse() + assertThat(initialState.permissionAlreadyDenied).isFalse() + assertThat(initialState.showDialog).isFalse() + } + } +} diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts new file mode 100644 index 0000000000..c4d78b432d --- /dev/null +++ b/libraries/push/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.push.api" +} + +dependencies { + implementation(libs.androidx.corektx) + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushproviders.api) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt new file mode 100644 index 0000000000..1a0eb7a93c --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.api + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider + +interface PushService { + // TODO Move away + fun notificationStyleChanged() + + fun getAvailablePushProviders(): List<PushProvider> + + /** + * Will unregister any previous pusher and register a new one with the provided [PushProvider]. + * + * The method has effect only if the [PushProvider] is different than the current one. + */ + suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) + + // TODO Move away + suspend fun testPush() +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt new file mode 100644 index 0000000000..9e8acc4d8f --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.api.gateway + +sealed class PushGatewayFailure : Throwable(cause = null) { + object PusherRejected : PushGatewayFailure() +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt new file mode 100644 index 0000000000..9a778195fa --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.api.notifications + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +interface NotificationDrawerManager { + fun clearMembershipNotificationForSession(sessionId: SessionId) + fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt new file mode 100644 index 0000000000..f478034063 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.api.store + +import kotlinx.coroutines.flow.Flow + +interface PushDataStore { + val pushCounterFlow: Flow<Int> +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts new file mode 100644 index 0000000000..b639f61b8b --- /dev/null +++ b/libraries/push/impl/build.gradle.kts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.22" +} + +android { + namespace = "io.element.android.libraries.push.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.security.crypto) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) + implementation(libs.coil) + + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.network) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + api(projects.libraries.pushproviders.api) + api(projects.libraries.pushstore.api) + api(projects.libraries.push.api) + + implementation(projects.services.analytics.api) + implementation(projects.services.appnavstate.api) + implementation(projects.services.toolbox.api) + + // TODO Temporary use the deprecated LocalBroadcastManager, to be changed later. + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) +} diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..6085ffe4a4 --- /dev/null +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +~ Copyright (c) 2023 New Vector Ltd +~ +~ 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.POST_NOTIFICATIONS" /> + <application> + <receiver + android:name=".notifications.TestNotificationReceiver" + android:exported="false" /> + <receiver + android:name=".notifications.NotificationBroadcastReceiver" + android:enabled="true" + android:exported="false" /> + </application> +</manifest> diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt new file mode 100644 index 0000000000..b16908269d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPushService @Inject constructor( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val pushersManager: PushersManager, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, +) : PushService { + override fun notificationStyleChanged() { + defaultNotificationDrawerManager.notificationStyleChanged() + } + + override fun getAvailablePushProviders(): List<PushProvider> { + return pushProviders.sortedBy { it.index } + } + + /** + * Get current push provider, compare with provided one, then unregister and register if different, and store change. + */ + override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + val userPushStore = userPushStoreFactory.create(matrixClient.sessionId) + val currentPushProviderName = userPushStore.getPushProviderName() + if (currentPushProviderName != pushProvider.name) { + // Unregister previous one if any + pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient) + } + pushProvider.registerWith(matrixClient, distributor) + // Store new value + userPushStore.setPushProviderName(pushProvider.name) + } + + override suspend fun testPush() { + pushersManager.testPush() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt new file mode 100644 index 0000000000..eb37332f8f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl + +object NotificationConfig { + // TODO EAx Implement and set to true at some point + const val supportMarkAsReadAction = false + + // TODO EAx Implement and set to true at some point + const val supportQuickReplyAction = false +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt new file mode 100644 index 0000000000..81a86c5345 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.services.toolbox.api.appname.AppNameProvider +import timber.log.Timber +import javax.inject.Inject + +internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" + +private val loggerTag = LoggerTag("PushersManager", pushLoggerTag) + +@ContributesBinding(AppScope::class) +class PushersManager @Inject constructor( + // private val localeProvider: LocaleProvider, + private val appNameProvider: AppNameProvider, + // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, + private val pushClientSecret: PushClientSecret, + private val userPushStoreFactory: UserPushStoreFactory, +) : PusherSubscriber { + // TODO Move this to the PushProvider API + suspend fun testPush() { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "TODO", // unifiedPushHelper.getPushGateway() ?: return, + appId = PushConfig.pusher_app_id, + pushKey = "TODO", // unifiedPushHelper.getEndpointOrToken().orEmpty(), + eventId = TEST_EVENT_ID + ) + ) + } + + /** + * Register a pusher to the server if not done yet. + */ + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + val userDataStore = userPushStoreFactory.create(matrixClient.sessionId) + if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { + Timber.tag(loggerTag.value).d("Unnecessary to register again the same pusher") + } else { + // Register the pusher to the server + matrixClient.pushersService().setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ).fold( + { + userDataStore.setCurrentRegisteredPushKey(pushKey) + }, + { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") + } + ) + } + } + + private suspend fun createHttpPusher( + pushKey: String, + gateway: String, + userId: SessionId, + ): SetHttpPusherData = + SetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.pusher_app_id, + profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/, + lang = "en", // TODO localeProvider.current().language, + appDisplayName = appNameProvider.getAppName(), + deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(), + url = gateway, + defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId)) + ) + + /** + * Ex: {"cs":"sfvsdv"}. + */ + private fun createDefaultPayload(secretForUser: String): String { + return "{\"cs\":\"$secretForUser\"}" + } + + suspend fun registerEmailForPush(email: String) { + TODO() + /* + val currentSession = activeSessionHolder.getActiveSession() + val appName = appNameProvider.getAppName() + currentSession.pushersService().addEmailPusher( + email = email, + lang = localeProvider.current().language, + emailBranding = appName, + appDisplayName = appName, + deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE" + ) + */ + } + + fun getPusherForCurrentSession() {}/*: Pusher? { + val session = activeSessionHolder.getSafeActiveSession() ?: return null + val deviceId = session.sessionParams.deviceId + return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } + } + */ + + suspend fun unregisterEmailPusher(email: String) { + // val currentSession = activeSessionHolder.getSafeActiveSession() ?: return + // currentSession.pushersService().removeEmailPusher(email) + } + + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + matrixClient.pushersService().unsetHttpPusher() + } + + companion object { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt new file mode 100644 index 0000000000..823dd7f693 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.config + +object PushConfig { + /** + * Note: pusher_app_id cannot exceed 64 chars. + */ + const val pusher_app_id: String = "im.vector.app.android" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindsModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindsModule.kt new file mode 100644 index 0000000000..63c5198514 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindsModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager + +@Module +@ContributesTo(AppScope::class) +abstract class PushBindsModule { + @Binds + abstract fun bindNotificationDrawerManager( + defaultNotificationDrawerManager: DefaultNotificationDrawerManager + ): NotificationDrawerManager +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt new file mode 100644 index 0000000000..8e0dd3e6f2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.intent + +import android.content.Intent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId + +interface IntentProvider { + /** + * Provide an intent to start the application on a room or thread. + */ + fun getViewRoomIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + ): Intent + + /** + * Provide an intent to start the application on the invite list. + */ + fun getInviteListIntent(sessionId: SessionId): Intent +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt new file mode 100644 index 0000000000..3fa613d097 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.log + +import io.element.android.libraries.core.log.logger.LoggerTag + +internal val pushLoggerTag = LoggerTag("Push") +internal val notificationLoggerTag = LoggerTag("Notification", pushLoggerTag) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt new file mode 100644 index 0000000000..9cd4956dca --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.androidutils.throttler.FirstThrottler +import io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.api.store.PushDataStore +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** + * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and + * organise them in order to display them in the notification drawer. + * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. + */ +@SingleIn(AppScope::class) +class DefaultNotificationDrawerManager @Inject constructor( + private val pushDataStore: PushDataStore, + private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationRenderer: NotificationRenderer, + private val notificationEventPersistence: NotificationEventPersistence, + private val filteredEventDetector: FilteredEventDetector, + private val appNavigationStateService: AppNavigationStateService, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + private val buildMeta: BuildMeta, + private val matrixClientProvider: MatrixClientProvider, +) : NotificationDrawerManager { + /** + * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. + */ + private val notificationState by lazy { createInitialNotificationState() } + private val firstThrottler = FirstThrottler(200) + + // TODO EAx add a setting per user for this + private var useCompleteNotificationFormat = true + + init { + // Observe application state + coroutineScope.launch { + appNavigationStateService.appNavigationState + .collect { onAppNavigationStateChange(it.navigationState) } + } + } + + private fun onAppNavigationStateChange(navigationState: NavigationState) { + when (navigationState) { + NavigationState.Root -> {} + is NavigationState.Session -> {} + is NavigationState.Space -> {} + is NavigationState.Room -> { + // Cleanup notification for current room + clearMessagesForRoom(navigationState.parentSpace.parentSession.sessionId, navigationState.roomId) + } + is NavigationState.Thread -> { + onEnteringThread( + navigationState.parentRoom.parentSpace.parentSession.sessionId, + navigationState.parentRoom.roomId, + navigationState.threadId + ) + } + } + } + + private fun createInitialNotificationState(): NotificationState { + val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents -> + NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) + }) + val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList() + return NotificationState(queuedEvents, renderedEvents) + } + + private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.d("onNotifiableEventReceived(): $notifiableEvent") + } else { + Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") + } + + if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) { + Timber.d("onNotifiableEventReceived(): ignore the event") + return + } + + add(notifiableEvent) + } + + /** + * Should be called as soon as a new event is ready to be displayed. + * The notification corresponding to this event will not be displayed until + * #refreshNotificationDrawer() is called. + * Events might be grouped and there might not be one notification per event! + */ + fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + updateEvents { + it.onNotifiableEventReceived(notifiableEvent) + } + } + + /** + * Clear all known events and refresh the notification drawer. + */ + fun clearAllEvents(sessionId: SessionId) { + updateEvents { + it.clearMessagesForSession(sessionId) + } + } + + /** + * Should be called when the application is currently opened and showing timeline for the given roomId. + * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. + * Can also be called when a notification for this room is dismissed by the user. + */ + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + updateEvents { + it.clearMessagesForRoom(sessionId, roomId) + } + } + + override fun clearMembershipNotificationForSession(sessionId: SessionId) { + updateEvents { + it.clearMembershipNotificationForSession(sessionId) + } + } + + /** + * Clear invitation notification for the provided room. + */ + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + updateEvents { + it.clearMembershipNotificationForRoom(sessionId, roomId) + } + } + + /** + * Clear the notifications for a single event. + */ + fun clearEvent(eventId: EventId) { + updateEvents { + it.clearEvent(eventId) + } + } + + /** + * Should be called when the application is currently opened and showing timeline for the given threadId. + * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. + */ + private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + updateEvents { + it.clearMessagesForThread(sessionId, roomId, threadId) + } + } + + // TODO EAx Must be per account + fun notificationStyleChanged() { + updateEvents { + val newSettings = true // pushDataStore.useCompleteNotificationFormat() + if (newSettings != useCompleteNotificationFormat) { + // Settings has changed, remove all current notifications + notificationRenderer.cancelAllNotifications() + useCompleteNotificationFormat = newSettings + } + } + } + + private fun updateEvents(action: DefaultNotificationDrawerManager.(NotificationEventQueue) -> Unit) { + notificationState.updateQueuedEvents(this) { queuedEvents, _ -> + action(queuedEvents) + } + coroutineScope.refreshNotificationDrawer() + } + + private fun CoroutineScope.refreshNotificationDrawer() = launch { + // Implement last throttler + val canHandle = firstThrottler.canHandle() + Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms") + withContext(dispatchers.io) { + delay(canHandle.waitMillis()) + try { + refreshNotificationDrawerBg() + } catch (throwable: Throwable) { + // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer + Timber.w(throwable, "refreshNotificationDrawerBg failure") + } + } + } + + private suspend fun refreshNotificationDrawerBg() { + Timber.v("refreshNotificationDrawerBg()") + val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> + notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also { + queuedEvents.clearAndAdd(it.onlyKeptEvents()) + } + } + + if (notificationState.hasAlreadyRendered(eventsToRender)) { + Timber.d("Skipping notification update due to event list not changing") + } else { + notificationState.clearAndAddRenderedEvents(eventsToRender) + renderEvents(eventsToRender) + persistEvents() + } + } + + private fun persistEvents() { + notificationState.queuedEvents { queuedEvents -> + notificationEventPersistence.persistEvents(queuedEvents) + } + } + + private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) { + // Group by sessionId + val eventsForSessions = eventsToRender.groupBy { + it.event.sessionId + } + + eventsForSessions.forEach { (sessionId, notifiableEvents) -> + val currentUser = tryOrNull( + onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") }, + operation = { + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() + // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash + val myUserDisplayName = client.loadUserDisplayName().getOrNull() ?: sessionId.value + val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() + MatrixUser( + userId = sessionId, + displayName = myUserDisplayName, + avatarUrl = userAvatarUrl + ) + } + ) ?: MatrixUser( + userId = sessionId, + displayName = sessionId.value, + avatarUrl = null + ) + + notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt new file mode 100644 index 0000000000..a24f088998 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import javax.inject.Inject + +class FilteredEventDetector @Inject constructor( + //private val activeSessionDataSource: ActiveSessionDataSource +) { + + /** + * Returns true if the given event should be ignored. + * Used to skip notifications if a non expected message is received. + */ + fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean { + /* TODO EAx + val session = activeSessionDataSource.currentValue?.orNull() ?: return false + + if (notifiableEvent is NotifiableMessageEvent) { + val room = session.getRoom(notifiableEvent.roomId) ?: return false + val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false + return timelineEvent.shouldBeIgnored() + } + + */ + return false + } + + /** + * Whether the timeline event should be ignored. + */ + /* + private fun TimelineEvent.shouldBeIgnored(): Boolean { + if (root.isVoiceMessage()) { + val audioEvent = root.asMessageAudioEvent() + // if the event is a voice message related to a voice broadcast, only show the event on the first chunk. + return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1 + } + + return false + } + */ +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt new file mode 100644 index 0000000000..50f1b88783 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom +import io.element.android.services.appnavstate.api.AppNavigationStateService +import timber.log.Timber +import javax.inject.Inject + +private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>> + +class NotifiableEventProcessor @Inject constructor( + private val outdatedDetector: OutdatedEventDetector, + private val appNavigationStateService: AppNavigationStateService, +) { + + fun process( + queuedEvents: List<NotifiableEvent>, + renderedEvents: ProcessedEvents, + ): ProcessedEvents { + val appState = appNavigationStateService.appNavigationState.value + val processedEvents = queuedEvents.map { + val type = when (it) { + is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP + is NotifiableMessageEvent -> when { + it.shouldIgnoreEventInRoom(appState) -> { + ProcessedEvent.Type.REMOVE + .also { Timber.d("notification message removed due to currently viewing the same room or thread") } + } + outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE + .also { Timber.d("notification message removed due to being read") } + else -> ProcessedEvent.Type.KEEP + } + is SimpleNotifiableEvent -> when (it.type) { + EventType.REDACTION -> ProcessedEvent.Type.REMOVE + else -> ProcessedEvent.Type.KEEP + } + is FallbackNotifiableEvent -> when { + it.shouldIgnoreEventInRoom(appState) -> { + ProcessedEvent.Type.REMOVE + .also { Timber.d("notification fallback removed due to currently viewing the same room or thread") } + } + else -> ProcessedEvent.Type.KEEP + } + } + ProcessedEvent(type, it) + } + + val removedEventsDiff = renderedEvents.filter { renderedEvent -> + queuedEvents.none { it.eventId == renderedEvent.event.eventId } + }.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) } + + return removedEventsDiff + processedEvents + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt new file mode 100644 index 0000000000..dbdddf1ac9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.notification.NotificationContent +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("NotifiableEventResolver", pushLoggerTag) + +/** + * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. + * It is used as a bridge between the Event Thread and the NotificationDrawerManager. + * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, + * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. + */ +class NotifiableEventResolver @Inject constructor( + private val stringProvider: StringProvider, + // private val noticeEventFormatter: NoticeEventFormatter, + // private val displayableEventFormatter: DisplayableEventFormatter, + private val buildMeta: BuildMeta, + private val clock: SystemClock, + private val matrixClientProvider: MatrixClientProvider, +) { + + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { + // Restore session + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null + val notificationService = client.notificationService() + val notificationData = notificationService.getNotification( + userId = sessionId, + roomId = roomId, + eventId = eventId, + // FIXME should be true in the future, but right now it's broken + // (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658) + filterByPushRules = false, + ).onFailure { + Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") + }.getOrNull() + + // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event + return notificationData?.asNotifiableEvent(sessionId) + ?: fallbackNotifiableEvent(sessionId, roomId, eventId) + } + + private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? { + return when (val content = this.event.content) { + is NotificationContent.MessageLike.RoomMessage -> { + buildNotifiableMessageEvent( + sessionId = userId, + roomId = roomId, + eventId = eventId, + noisy = isNoisy, + timestamp = event.timestamp, + senderName = senderDisplayName, + senderId = senderId.value, + body = descriptionFromMessageContent(content), + imageUriString = event.contentUrl, + roomName = roomDisplayName, + roomIsDirect = isDirect, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + ) + } + is NotificationContent.StateEvent.RoomMemberContent -> { + if (content.membershipState == RoomMembershipState.INVITE) { + InviteNotifiableEvent( + sessionId = userId, + roomId = roomId, + eventId = eventId, + editedEventId = null, + canBeReplaced = true, + roomName = roomDisplayName, + noisy = isNoisy, + timestamp = event.timestamp, + soundName = null, + isRedacted = false, + isUpdated = false, + description = descriptionFromRoomMembershipContent(content, isDirect) ?: return null, + type = null, // TODO check if type is needed anymore + title = null, // TODO check if title is needed anymore + ) + } else { + null + } + } + else -> null + } + } + + private fun fallbackNotifiableEvent( + userId: SessionId, + roomId: RoomId, + eventId: EventId + ) = FallbackNotifiableEvent( + sessionId = userId, + roomId = roomId, + eventId = eventId, + editedEventId = null, + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + timestamp = clock.epochMillis(), + description = stringProvider.getString(R.string.notification_fallback_content), + ) + + private fun descriptionFromMessageContent( + content: NotificationContent.MessageLike.RoomMessage, + ): String { + return when (val messageType = content.messageType) { + is AudioMessageType -> messageType.body + is EmoteMessageType -> messageType.body + is FileMessageType -> messageType.body + is ImageMessageType -> messageType.body + is NoticeMessageType -> messageType.body + is TextMessageType -> messageType.body + is VideoMessageType -> messageType.body + is LocationMessageType -> messageType.body + is UnknownMessageType -> stringProvider.getString(CommonStrings.common_unsupported_event) + } + } + + private fun descriptionFromRoomMembershipContent( + content: NotificationContent.StateEvent.RoomMemberContent, + isDirectRoom: Boolean + ): String? { + return when (content.membershipState) { + RoomMembershipState.INVITE -> { + if (isDirectRoom) { + stringProvider.getString(R.string.notification_invite_body) + } else { + stringProvider.getString(R.string.notification_room_invite_body) + } + } + else -> null + } + } +} + +@Suppress("LongParameterList") +private fun buildNotifiableMessageEvent( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + editedEventId: EventId? = null, + canBeReplaced: Boolean = false, + noisy: Boolean, + timestamp: Long, + senderName: String?, + senderId: String?, + body: String?, + // We cannot use Uri? type here, as that could trigger a + // NotSerializableException when persisting this to storage + imageUriString: String? = null, + threadId: ThreadId? = null, + roomName: String? = null, + roomIsDirect: Boolean = false, + roomAvatarPath: String? = null, + senderAvatarPath: String? = null, + soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + outGoingMessage: Boolean = false, + outGoingMessageFailed: Boolean = false, + isRedacted: Boolean = false, + isUpdated: Boolean = false +) = NotifiableMessageEvent( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + editedEventId = editedEventId, + canBeReplaced = canBeReplaced, + noisy = noisy, + timestamp = timestamp, + senderName = senderName, + senderId = senderId, + body = body, + imageUriString = imageUriString, + threadId = threadId, + roomName = roomName, + roomIsDirect = roomIsDirect, + roomAvatarPath = roomAvatarPath, + senderAvatarPath = senderAvatarPath, + soundName = soundName, + outGoingMessage = outGoingMessage, + outGoingMessageFailed = outGoingMessageFailed, + isRedacted = isRedacted, + isUpdated = isUpdated +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt new file mode 100644 index 0000000000..b3f0b1e0f2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +data class NotificationAction( + val shouldNotify: Boolean, + val highlight: Boolean, + val soundName: String? +) + +/* +fun List<Action>.toNotificationAction(): NotificationAction { + var shouldNotify = false + var highlight = false + var sound: String? = null + forEach { action -> + when (action) { + is Action.Notify -> shouldNotify = true + is Action.DoNotNotify -> shouldNotify = false + is Action.Highlight -> highlight = action.highlight + is Action.Sound -> sound = action.sound + } + } + return NotificationAction(shouldNotify, highlight, sound) +} + */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt new file mode 100644 index 0000000000..6141079130 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.core.meta.BuildMeta +import javax.inject.Inject + +/** + * Util class for creating notifications. + * Note: Cannot inject ColorProvider in the constructor, because it requires an Activity + */ + +data class NotificationActionIds @Inject constructor( + private val buildMeta: BuildMeta, +) { + val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION" + val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION" + val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION" + val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION" + val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" + val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" + val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" + val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION" + val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION" + val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC" + val push = "${buildMeta.applicationId}.PUSH" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt new file mode 100644 index 0000000000..1ad38bf787 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBitmapLoader.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import coil.imageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import timber.log.Timber +import javax.inject.Inject + +class NotificationBitmapLoader @Inject constructor( + @ApplicationContext private val context: Context +) { + + /** + * Get icon of a room. + * @param path mxc url + */ + suspend fun getRoomBitmap(path: String?): Bitmap? { + if (path == null) { + return null + } + return loadRoomBitmap(path) + } + + private suspend fun loadRoomBitmap(path: String): Bitmap? { + return try { + val imageRequest = ImageRequest.Builder(context) + .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024))) + .transformations(CircleCropTransformation()) + .build() + val result = context.imageLoader.execute(imageRequest) + result.drawable?.toBitmap() + } catch (e: Throwable) { + Timber.e(e, "Unable to load room bitmap") + null + } + } + + /** + * Get icon of a user. + * Before Android P, this does nothing because the icon won't be used + * @param path mxc url + */ + suspend fun getUserIcon(path: String?): IconCompat? { + if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return null + } + + return loadUserIcon(path) + } + + private suspend fun loadUserIcon(path: String): IconCompat? { + return try { + val imageRequest = ImageRequest.Builder(context) + .data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024))) + .transformations(CircleCropTransformation()) + .build() + val result = context.imageLoader.execute(imageRequest) + val bitmap = result.drawable?.toBitmap() + return bitmap?.let { IconCompat.createWithBitmap(it) } + } catch (e: Throwable) { + Timber.e(e, "Unable to load user bitmap") + null + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt new file mode 100644 index 0000000000..d5df1001ca --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.log.notificationLoggerTag +import io.element.android.services.toolbox.api.systemclock.SystemClock +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationLoggerTag) + +/** + * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). + */ +class NotificationBroadcastReceiver : BroadcastReceiver() { + + @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager + + //@Inject lateinit var activeSessionHolder: ActiveSessionHolder + //@Inject lateinit var analyticsTracker: AnalyticsTracker + @Inject lateinit var clock: SystemClock + @Inject lateinit var actionIds: NotificationActionIds + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || context == null) return + context.bindings<NotificationBroadcastReceiverBindings>().inject(this) + Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent") + val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return + val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) + val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId) + when (intent.action) { + actionIds.smartReply -> + handleSmartReply(intent, context) + actionIds.dismissRoom -> if (roomId != null) { + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + } + actionIds.dismissSummary -> + defaultNotificationDrawerManager.clearAllEvents(sessionId) + actionIds.dismissInvite -> if (roomId != null) { + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + } + actionIds.dismissEvent -> if (eventId != null) { + defaultNotificationDrawerManager.clearEvent(eventId) + } + actionIds.markRoomRead -> if (roomId != null) { + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + handleMarkAsRead(sessionId, roomId) + } + actionIds.join -> if (roomId != null) { + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + handleJoinRoom(sessionId, roomId) + } + actionIds.reject -> if (roomId != null) { + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + handleRejectRoom(sessionId, roomId) + } + } + } + + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) { + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + val room = session.getRoom(roomId) + if (room != null) { + session.coroutineScope.launch { + tryOrNull { + session.roomService().joinRoom(room.roomId) + analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification)) + } + } + } + } + + */ + } + + private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) { + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + session.coroutineScope.launch { + tryOrNull { session.roomService().leaveRoom(roomId) } + } + } + + */ + } + + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { + /* + activeSessionHolder.getActiveSession().let { session -> + val room = session.getRoom(roomId) + if (room != null) { + session.coroutineScope.launch { + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } + } + } + } + + */ + } + + private fun handleSmartReply(intent: Intent, context: Context) { + val message = getReplyMessage(intent) + val sessionId = intent.getStringExtra(KEY_SESSION_ID)?.let(::SessionId) + val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) + val threadId = intent.getStringExtra(KEY_THREAD_ID)?.let(::ThreadId) + + if (message.isNullOrBlank() || roomId == null) { + // ignore this event + // Can this happen? should we update notification? + return + } + /* + activeSessionHolder.getActiveSession().let { session -> + session.getRoom(roomId)?.let { room -> + sendMatrixEvent(message, threadId, session, room, context) + } + } + + */ + } + + /* + private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) { + if (threadId != null) { + room.relationService().replyInThread( + rootThreadEventId = threadId, + replyInThreadText = message, + ) + } else { + room.sendService().sendTextMessage(message) + } + + // Create a new event to be displayed in the notification drawer, right now + + val notifiableMessageEvent = NotifiableMessageEvent( + // Generate a Fake event id + eventId = UUID.randomUUID().toString(), + editedEventId = null, + noisy = false, + timestamp = clock.epochMillis(), + senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName + ?: context?.getString(R.string.notification_sender_me), + senderId = session.myUserId, + body = message, + imageUriString = null, + roomId = room.roomId, + threadId = threadId, + roomName = room.roomSummary()?.displayName ?: room.roomId, + roomIsDirect = room.roomSummary()?.isDirect == true, + outGoingMessage = true, + canBeReplaced = false + ) + + notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) } + + /* + // TODO Error cannot be managed the same way than in Riot + + val event = Event(mxMessage, session.credentials.userId, roomId) + room.storeOutgoingEvent(event) + room.sendEvent(event, object : MatrixCallback<Void?> { + override fun onSuccess(info: Void?) { + Timber.v("Send message : onSuccess ") + } + + override fun onNetworkError(e: Exception) { + Timber.e(e, "Send message : onNetworkError") + onSmartReplyFailed(e.localizedMessage) + } + + override fun onMatrixError(e: MatrixError) { + Timber.v("Send message : onMatrixError " + e.message) + if (e is MXCryptoError) { + Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.detailedErrorDescription) + } else { + Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() + onSmartReplyFailed(e.localizedMessage) + } + } + + override fun onUnexpectedError(e: Exception) { + Timber.e(e, "Send message : onUnexpectedError " + e.message) + onSmartReplyFailed(e.message) + } + + + fun onSmartReplyFailed(reason: String?) { + val notifiableMessageEvent = NotifiableMessageEvent( + event.eventId, + false, + clock.epochMillis(), + session.myUser?.displayname + ?: context?.getString(R.string.notification_sender_me), + session.myUserId, + message, + roomId, + room.getRoomDisplayName(context), + room.isDirect) + notifiableMessageEvent.outGoingMessage = true + notifiableMessageEvent.outGoingMessageFailed = true + + VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) + VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null) + } + }) + */ + } + + */ + + private fun getReplyMessage(intent: Intent?): String? { + if (intent != null) { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + if (remoteInput != null) { + return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() + } + } + return null + } + + companion object { + const val KEY_SESSION_ID = "sessionID" + const val KEY_ROOM_ID = "roomID" + const val KEY_THREAD_ID = "threadID" + const val KEY_EVENT_ID = "eventID" + const val KEY_TEXT_REPLY = "key_text_reply" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt new file mode 100644 index 0000000000..ae936e693b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface NotificationBroadcastReceiverBindings { + fun inject(receiver: NotificationBroadcastReceiver) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000000..2cb01ba2f7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +class NotificationDisplayer @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val notificationManager = NotificationManagerCompat.from(context) + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Timber.w("Not allowed to notify.") + return + } + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + fun cancelAllNotifications() { + // Keep this try catch (reported by GA) + try { + notificationManager.cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed") + } + } + + @SuppressLint("LaunchActivityFromNotification") + fun displayDiagnosticNotification(notification: Notification) { + showNotificationMessage( + tag = "DIAGNOSTIC", + id = NOTIFICATION_ID_DIAGNOSTIC, + notification = notification + ) + } + + /** + * Cancel the foreground notification service. + */ + fun cancelNotificationForegroundService() { + notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) + } + + companion object { + /* ========================================================================================== + * IDs for notifications + * ========================================================================================== */ + + /** + * Identifier of the foreground notification used to keep the application alive + * when it runs in background. + * This notification, which is not removable by the end user, displays what + * the application is doing while in background. + */ + private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61 + + private const val NOTIFICATION_ID_DIAGNOSTIC = 888 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt new file mode 100644 index 0000000000..613a8d2bb7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.content.Context +import io.element.android.libraries.androidutils.file.EncryptedFileFactory +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.log.notificationLoggerTag +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import timber.log.Timber +import java.io.File +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import javax.inject.Inject + +private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache" +private const val FILE_NAME = "notifications.bin" + +private val loggerTag = LoggerTag("NotificationEventPersistence", notificationLoggerTag) + +class NotificationEventPersistence @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val file by lazy { + deleteLegacyFileIfAny() + context.getDatabasePath(FILE_NAME) + } + + private val encryptedFile by lazy { + EncryptedFileFactory(context).create(file) + } + + fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue { + val rawEvents: ArrayList<NotifiableEvent>? = file + .takeIf { it.exists() } + ?.let { + try { + encryptedFile.openFileInput().use { fis -> + ObjectInputStream(fis).use { ois -> + @Suppress("UNCHECKED_CAST") + ois.readObject() as? ArrayList<NotifiableEvent> + } + }.also { + Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)") + } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info") + null + } + } + return factory(rawEvents.orEmpty()) + } + + fun persistEvents(queuedEvents: NotificationEventQueue) { + Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") + // Always delete file before writing, or encryptedFile.openFileOutput() will throw + file.safeDelete() + if (queuedEvents.isEmpty()) return + try { + encryptedFile.openFileOutput().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(queuedEvents.rawEvents()) + } + } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info") + } + } + + private fun deleteLegacyFileIfAny() { + tryOrNull { + File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt new file mode 100644 index 0000000000..97b90476b0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import timber.log.Timber + +data class NotificationEventQueue constructor( + private val queue: MutableList<NotifiableEvent>, + /** + * An in memory FIFO cache of the seen events. + * Acts as a notification debouncer to stop already dismissed push notifications from + * displaying again when the /sync response is delayed. + */ + // TODO Should be per session, so the key must be Pair<SessionId, EventId>. + private val seenEventIds: CircularCache<EventId> +) { + + fun markRedacted(eventIds: List<EventId>) { + eventIds.forEach { redactedId -> + queue.replace(redactedId) { + when (it) { + is InviteNotifiableEvent -> it.copy(isRedacted = true) + is NotifiableMessageEvent -> it.copy(isRedacted = true) + is SimpleNotifiableEvent -> it.copy(isRedacted = true) + is FallbackNotifiableEvent -> it.copy(isRedacted = true) + } + } + } + } + + // TODO EAx call this + fun syncRoomEvents(roomsLeft: Collection<RoomId>, roomsJoined: Collection<RoomId>) { + if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) { + queue.removeAll { + when (it) { + is NotifiableMessageEvent -> roomsLeft.contains(it.roomId) + is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId) + is SimpleNotifiableEvent -> false + is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId) + } + } + } + } + + fun isEmpty() = queue.isEmpty() + + fun clearAndAdd(events: List<NotifiableEvent>) { + queue.clear() + queue.addAll(events) + } + + fun clear() { + queue.clear() + } + + fun add(notifiableEvent: NotifiableEvent) { + val existing = findExistingById(notifiableEvent) + val edited = findEdited(notifiableEvent) + when { + existing != null -> { + if (existing.canBeReplaced) { + // Use the event coming from the event stream as it may contains more info than + // the fcm one (like type/content/clear text) (e.g when an encrypted message from + // FCM should be update with clear text after a sync) + // In this case the message has already been notified, and might have done some noise + // So we want the notification to be updated even if it has already been displayed + // Use setOnlyAlertOnce to ensure update notification does not interfere with sound + // from first notify invocation as outlined in: + // https://developer.android.com/training/notify-user/build-notification#Updating + replace(replace = existing, with = notifiableEvent) + } else { + // keep the existing one, do not replace + } + } + edited != null -> { + // Replace the existing notification with the new content + replace(replace = edited, with = notifiableEvent) + } + seenEventIds.contains(notifiableEvent.eventId) -> { + // we've already seen the event, lets skip + Timber.d("onNotifiableEventReceived(): skipping event, already seen") + } + else -> { + seenEventIds.put(notifiableEvent.eventId) + queue.add(notifiableEvent) + } + } + } + + private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? { + return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId } + } + + private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? { + return notifiableEvent.editedEventId?.let { editedId -> + queue.firstOrNull { + it.eventId == editedId || it.editedEventId == editedId + } + } + } + + private fun replace(replace: NotifiableEvent, with: NotifiableEvent) { + queue.remove(replace) + queue.add( + when (with) { + is InviteNotifiableEvent -> with.copy(isUpdated = true) + is NotifiableMessageEvent -> with.copy(isUpdated = true) + is SimpleNotifiableEvent -> with.copy(isUpdated = true) + is FallbackNotifiableEvent -> with.copy(isUpdated = true) + } + ) + } + + fun clearEvent(eventId: EventId) { + queue.removeAll { it.eventId == eventId } + } + + fun clearMembershipNotificationForSession(sessionId: SessionId) { + Timber.d("clearMemberShipOfSession $sessionId") + queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId } + } + + fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + Timber.d("clearMemberShipOfRoom $sessionId, $roomId") + queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } + } + + fun clearMessagesForSession(sessionId: SessionId) { + Timber.d("clearMessagesForSession $sessionId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId } + } + + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + Timber.d("clearMessageEventOfRoom $sessionId, $roomId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId } + } + + fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId") + queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId } + } + + fun rawEvents(): List<NotifiableEvent> = queue +} + +private fun MutableList<NotifiableEvent>.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) { + val indexToReplace = indexOfFirst { it.eventId == eventId } + if (indexToReplace == -1) { + return + } + set(indexToReplace, block(get(indexToReplace))) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt new file mode 100644 index 0000000000..9dd6c7d4cd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import javax.inject.Inject + +private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>> + +// TODO Find a better name, it clashes with io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +class NotificationFactory @Inject constructor( + private val notificationFactory: NotificationFactory, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator +) { + + suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications( + currentUser: MatrixUser, + ): List<RoomNotification> { + return map { (roomId, events) -> + when { + events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) + else -> { + val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } + roomGroupMessageCreator.createRoomMessage( + currentUser = currentUser, + events = messageEvents, + roomId = roomId, + ) + } + } + } + } + + private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { + it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + @JvmName("toNotificationsInviteNotifiableEvent") + fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(): List<OneShotNotification> { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationFactory.createRoomInvitationNotification(event), + OneShotNotification.Append.Meta( + key = event.roomId.value, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + @JvmName("toNotificationsSimpleNotifiableEvent") + fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(): List<OneShotNotification> { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationFactory.createSimpleEventNotification(event), + OneShotNotification.Append.Meta( + key = event.eventId.value, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationFactory.createFallbackNotification(event), + OneShotNotification.Append.Meta( + key = event.eventId.value, + summaryLine = event.description.orEmpty(), + isNoisy = false, + timestamp = event.timestamp + ) + ) + } + } + } + + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List<RoomNotification>, + invitationNotifications: List<OneShotNotification>, + simpleNotifications: List<OneShotNotification>, + fallbackNotifications: List<OneShotNotification>, + useCompleteNotificationFormat: Boolean + ): SummaryNotification { + val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta } + val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } + val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } + val fallbackMeta = fallbackNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } + return when { + roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + currentUser = currentUser, + roomNotifications = roomMeta, + invitationNotifications = invitationMeta, + simpleNotifications = simpleMeta, + fallbackNotifications = fallbackMeta, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + ) + } + } +} + +sealed interface RoomNotification { + data class Removed(val roomId: RoomId) : RoomNotification + data class Message(val notification: Notification, val meta: Meta) : RoomNotification { + data class Meta( + val roomId: RoomId, + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val shouldBing: Boolean + ) + } +} + +sealed interface OneShotNotification { + data class Removed(val key: String) : OneShotNotification + data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { + data class Meta( + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, + ) + } +} + +sealed interface SummaryNotification { + object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt new file mode 100644 index 0000000000..050edfcc11 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject +import kotlin.math.abs + +class NotificationIdProvider @Inject constructor() { + fun getSummaryNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID + } + + fun getRoomMessagesNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_MESSAGES_NOTIFICATION_ID + } + + fun getRoomEventNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_EVENT_NOTIFICATION_ID + } + + fun getRoomInvitationNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID + } + + fun getFallbackNotificationId(sessionId: SessionId): Int { + return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID + } + + private fun getOffset(sessionId: SessionId): Int { + // Compute a int from a string with a low risk of collision. + return abs(sessionId.value.hashCode() % 100_000) * 10 + } + + companion object { + private const val FALLBACK_NOTIFICATION_ID = -1 + private const val SUMMARY_NOTIFICATION_ID = 0 + private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + private const val ROOM_EVENT_NOTIFICATION_ID = 2 + private const val ROOM_INVITATION_NOTIFICATION_ID = 3 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt new file mode 100644 index 0000000000..a6179b3ec8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import timber.log.Timber +import javax.inject.Inject + +class NotificationRenderer @Inject constructor( + private val notificationIdProvider: NotificationIdProvider, + private val notificationDisplayer: NotificationDisplayer, + private val notificationFactory: NotificationFactory, +) { + + suspend fun render( + currentUser: MatrixUser, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List<ProcessedEvent<NotifiableEvent>> + ) { + val groupedEvents = eventsToProcess.groupByType() + with(notificationFactory) { + val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser) + val invitationNotifications = groupedEvents.invitationEvents.toNotifications() + val simpleNotifications = groupedEvents.simpleEvents.toNotifications() + val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications() + val summaryNotification = createSummaryNotification( + currentUser = currentUser, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + + // Remove summary first to avoid briefly displaying it after dismissing the last notification + if (summaryNotification == SummaryNotification.Removed) { + Timber.d("Removing summary notification") + notificationDisplayer.cancelNotificationMessage( + tag = null, + id = notificationIdProvider.getSummaryNotificationId(currentUser.userId) + ) + } + + roomNotifications.forEach { wrapper -> + when (wrapper) { + is RoomNotification.Removed -> { + Timber.d("Removing room messages notification ${wrapper.roomId}") + notificationDisplayer.cancelNotificationMessage( + tag = wrapper.roomId.value, + id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId) + ) + } + is RoomNotification.Message -> if (useCompleteNotificationFormat) { + Timber.d("Updating room messages notification ${wrapper.meta.roomId}") + notificationDisplayer.showNotificationMessage( + tag = wrapper.meta.roomId.value, + id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), + notification = wrapper.notification + ) + } + } + } + + invitationNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing invitation notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage( + tag = wrapper.key, + id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId) + ) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating invitation notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage( + tag = wrapper.meta.key, + id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), + notification = wrapper.notification + ) + } + } + } + + simpleNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing simple notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage( + tag = wrapper.key, + id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId) + ) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating simple notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage( + tag = wrapper.meta.key, + id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId), + notification = wrapper.notification + ) + } + } + } + + fallbackNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing fallback notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage( + tag = wrapper.key, + id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) + ) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating fallback notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage( + tag = wrapper.meta.key, + id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), + notification = wrapper.notification + ) + } + } + } + + // Update summary last to avoid briefly displaying it before other notifications + if (summaryNotification is SummaryNotification.Update) { + Timber.d("Updating summary notification") + notificationDisplayer.showNotificationMessage( + tag = null, + id = notificationIdProvider.getSummaryNotificationId(currentUser.userId), + notification = summaryNotification.notification + ) + } + } + } + + fun cancelAllNotifications() { + notificationDisplayer.cancelAllNotifications() + } +} + +private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents { + val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap() + val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList() + val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList() + val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList() + forEach { + when (val event = it.event) { + is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) + is NotifiableMessageEvent -> { + val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } + roomEvents.add(it.castedToEventType()) + } + is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) + is FallbackNotifiableEvent -> { + fallbackEvents.add(it.castedToEventType()) + } + } + } + return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents) +} + +@Suppress("UNCHECKED_CAST") +private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T> + +data class GroupedNotificationEvents( + val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>, + val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>, + val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>, + val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt new file mode 100644 index 0000000000..4737e891aa --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent + +class NotificationState( + /** + * The notifiable events queued for rendering or currently rendered. + * + * This is our source of truth for notifications, any changes to this list will be rendered as notifications. + * When events are removed the previously rendered notifications will be cancelled. + * When adding or updating, the notifications will be notified. + * + * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id. + */ + private val queuedEvents: NotificationEventQueue, + + /** + * The last known rendered notifiable events. + * We keep track of them in order to know which events have been removed from the eventList + * allowing us to cancel any notifications previous displayed by now removed events + */ + private val renderedEvents: MutableList<ProcessedEvent<NotifiableEvent>>, +) { + + fun <T> updateQueuedEvents( + drawerManager: DefaultNotificationDrawerManager, + action: DefaultNotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T + ): T { + return synchronized(queuedEvents) { + action(drawerManager, queuedEvents, renderedEvents) + } + } + + fun clearAndAddRenderedEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) { + renderedEvents.clear() + renderedEvents.addAll(eventsToRender) + } + + fun hasAlreadyRendered(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) = renderedEvents == eventsToRender + + fun queuedEvents(block: (NotificationEventQueue) -> Unit) { + synchronized(queuedEvents) { + block(queuedEvents) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt new file mode 100644 index 0000000000..5b15dc78d2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import javax.inject.Inject + +class OutdatedEventDetector @Inject constructor( + /// private val activeSessionDataSource: ActiveSessionDataSource +) { + + /** + * Returns true if the given event is outdated. + * Used to clean up notifications if a displayed message has been read on an + * other device. + */ + fun isMessageOutdated(notifiableEvent: NotifiableEvent): Boolean { + /* TODO EAx + val session = activeSessionDataSource.currentValue?.orNull() ?: return false + + if (notifiableEvent is NotifiableMessageEvent) { + val eventID = notifiableEvent.eventId + val roomID = notifiableEvent.roomId + val room = session.getRoom(roomID) ?: return false + return room.readService().isEventRead(eventID) + } + + */ + return false + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt new file mode 100644 index 0000000000..2e91ca3467 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +data class ProcessedEvent<T>( + val type: Type, + val event: T +) { + enum class Type { + KEEP, + REMOVE + } +} + +fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent -> + processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt new file mode 100644 index 0000000000..734c34b051 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Data class to hold information about a group of notifications for a room. + */ +data class RoomEventGroupInfo( + val sessionId: SessionId, + val roomId: RoomId, + val roomDisplayName: String, + val isDirect: Boolean = false +) { + // An event in the list has not yet been display + var hasNewEvent: Boolean = false + + // true if at least one on the not yet displayed event is noisy + var shouldBing: Boolean = false + var customSound: String? = null + var hasSmartReplyError: Boolean = false + var isUpdated: Boolean = false +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000000..5f2f6db263 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.graphics.Bitmap +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug +import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import javax.inject.Inject + +class RoomGroupMessageCreator @Inject constructor( + private val bitmapLoader: NotificationBitmapLoader, + private val stringProvider: StringProvider, + private val notificationFactory: NotificationFactory +) { + + suspend fun createRoomMessage( + currentUser: MatrixUser, + events: List<NotifiableMessageEvent>, + roomId: RoomId, + ): RoomNotification.Message { + val lastKnownRoomEvent = events.last() + val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)" + val roomIsGroup = !lastKnownRoomEvent.roomIsDirect + val style = NotificationCompat.MessagingStyle( + Person.Builder() + .setName(currentUser.displayName?.annotateForDebug(50)) + .setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl)) + .setKey(lastKnownRoomEvent.sessionId.value) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51) + it.isGroupConversation = roomIsGroup + it.addMessagesFromEvents(events) + } + + val tickerText = if (roomIsGroup) { + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + } else { + stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) + } + + val largeBitmap = getRoomBitmap(events) + + val lastMessageTimestamp = events.last().timestamp + val smartReplyErrors = events.filter { it.isSmartReplyError() } + val messageCount = (events.size - smartReplyErrors.size) + val meta = RoomNotification.Message.Meta( + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), + messageCount = messageCount, + latestTimestamp = lastMessageTimestamp, + roomId = roomId, + shouldBing = events.any { it.noisy } + ) + return RoomNotification.Message( + notificationFactory.createMessagesListNotification( + style, + RoomEventGroupInfo( + sessionId = currentUser.userId, + roomId = roomId, + roomDisplayName = roomName, + isDirect = !roomIsGroup, + ).also { + it.hasSmartReplyError = smartReplyErrors.isNotEmpty() + it.shouldBing = meta.shouldBing + it.customSound = events.last().soundName + it.isUpdated = events.last().isUpdated + }, + threadId = lastKnownRoomEvent.threadId, + largeIcon = largeBitmap, + lastMessageTimestamp, + tickerText + ), + meta + ) + } + + private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderName?.annotateForDebug(70)) + .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath)) + .setKey(event.senderId) + .build() + } + when { + event.isSmartReplyError() -> addMessage( + stringProvider.getString(R.string.notification_inline_reply_failed), + event.timestamp, + senderPerson + ) + else -> { + val message = NotificationCompat.MessagingStyle.Message( + event.body?.annotateForDebug(71), + event.timestamp, + senderPerson + ).also { message -> + event.imageUri?.let { + message.setData("image/", it) + } + } + addMessage(message) + } + } + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List<NotifiableMessageEvent>, roomName: String, roomIsDirect: Boolean): CharSequence { + return try { + when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } catch (e: Throwable) { + // String not found or bad format + Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") + roomName + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence { + return if (roomIsDirect) { + buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append(event.senderName) + append(": ") + } + append(event.description) + } + } else { + buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append(roomName) + append(": ") + event.senderName + append(" ") + } + append(event.description) + } + } + } + + private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { + // Use the last event (most recent?) + return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath } + ?.let { bitmapLoader.getRoomBitmap(it) } + } +} + +private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..f999456107 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug +import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +/** + * ======== Build summary notification ========= + * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for + * your group using snippets of text from each notification. The user can expand this + * notification to see each separate notification. + * To support older versions, which cannot show a nested group of notifications, + * you must create an extra notification that acts as the summary. + * This appears as the only notification and the system hides all the others. + * So this summary should include a snippet from all the other notifications, + * which the user can tap to open your app. + * The behavior of the group summary may vary on some device types such as wearables. + * To ensure the best experience on all devices and versions, always include a group summary when you create a group + * https://developer.android.com/training/notify-user/group + */ +class SummaryGroupMessageCreator @Inject constructor( + private val stringProvider: StringProvider, + private val notificationFactory: NotificationFactory, +) { + + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List<RoomNotification.Message.Meta>, + invitationNotifications: List<OneShotNotification.Append.Meta>, + simpleNotifications: List<OneShotNotification.Append.Meta>, + fallbackNotifications: List<OneShotNotification.Append.Meta>, + useCompleteNotificationFormat: Boolean + ): Notification { + val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> + roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) } + invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) } + simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) } + fallbackNotifications.forEach { style.addLine(it.summaryLine) } + } + + val summaryIsNoisy = roomNotifications.any { it.shouldBing } || + invitationNotifications.any { it.isNoisy } || + simpleNotifications.any { it.isNoisy } + + val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount } + + val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp + ?: invitationNotifications.lastOrNull()?.timestamp + ?: simpleNotifications.last().timestamp + + // FIXME roomIdToEventMap.size is not correct, this is the number of rooms + val nbEvents = roomNotifications.size + simpleNotifications.size + val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle.annotateForDebug(43)) + //.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents).annotateForDebug(44)) + // Use account name now, for multi-session + .setSummaryText(currentUser.userId.value.annotateForDebug(44)) + return if (useCompleteNotificationFormat) { + notificationFactory.createSummaryListNotification( + currentUser, + summaryInboxStyle, + sumTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } else { + processSimpleGroupSummary( + currentUser, + summaryIsNoisy, + messageCount, + simpleNotifications.size, + invitationNotifications.size, + roomNotifications.size, + lastMessageTimestamp + ) + } + } + + private fun processSimpleGroupSummary( + currentUser: MatrixUser, + summaryIsNoisy: Boolean, + messageEventsCount: Int, + simpleEventsCount: Int, + invitationEventsCount: Int, + roomCount: Int, + lastMessageTimestamp: Long + ): Notification { + // Add the simple events as message (?) + val messageNotificationCount = messageEventsCount + simpleEventsCount + + val privacyTitle = if (invitationEventsCount > 0) { + val invitationsStr = stringProvider.getQuantityString( + R.plurals.notification_invitations, + invitationEventsCount, + invitationEventsCount + ) + if (messageNotificationCount > 0) { + // Invitation and message + val messageStr = stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + messageNotificationCount, messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString( + R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, + roomCount + ) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room_and_invitation, + messageStr, + roomStr, + invitationsStr + ) + } else { + // In one room + stringProvider.getString( + R.string.notification_unread_notified_messages_and_invitation, + messageStr, + invitationsStr + ) + } + } else { + // Only invitation + invitationsStr + } + } else { + // No invitation, only messages + val messageStr = stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + messageNotificationCount, + messageNotificationCount + ) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString( + R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, + roomCount + ) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room, + messageStr, + roomStr + ) + } else { + // In one room + messageStr + } + } + return notificationFactory.createSummaryListNotification( + currentUser = currentUser, + style = null, + compatSummary = privacyTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt new file mode 100644 index 0000000000..42c0fe61af --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager + +class TestNotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // Internal broadcast to any one interested + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt new file mode 100644 index 0000000000..0624b863ed --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.channels + +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.impl.R +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +/** + * on devices >= android O, we need to define a channel for each notifications. + */ +@SingleIn(AppScope::class) +class NotificationChannels @Inject constructor( + @ApplicationContext private val context: Context, + private val stringProvider: StringProvider, +) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + createNotificationChannels() + } + + /* ========================================================================================== + * Channel names + * ========================================================================================== */ + + /** + * Create notification channels. + */ + private fun createNotificationChannels() { + if (!supportNotificationChannels()) { + return + } + + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + + // Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE + // + currentTimeMillis). + // Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel + // Starting from this version the channel will not be dynamic + for (channel in notificationManager.notificationChannels) { + val channelId = channel.id + val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE" + if (channelId.startsWith(legacyBaseName)) { + notificationManager.deleteNotificationChannel(channelId) + } + } + // Migration - Remove deprecated channels + for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) { + notificationManager.getNotificationChannel(channelId)?.let { + notificationManager.deleteNotificationChannel(channelId) + } + } + + /** + * Default notification importance: shows everywhere, makes noise, but does not visually + * intrude. + */ + notificationManager.createNotificationChannel( + NotificationChannel( + NOISY_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" }, + NotificationManager.IMPORTANCE_DEFAULT + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_noisy) + enableVibration(true) + enableLights(true) + lightColor = accentColor + }) + + /** + * Low notification importance: shows everywhere, but is not intrusive. + */ + notificationManager.createNotificationChannel( + NotificationChannel( + SILENT_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" }, + NotificationManager.IMPORTANCE_LOW + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_silent) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + + notificationManager.createNotificationChannel( + NotificationChannel( + LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_listening_for_events).ifEmpty { "Listening for events" }, + NotificationManager.IMPORTANCE_MIN + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_listening_for_events) + setSound(null, null) + setShowBadge(false) + }) + + notificationManager.createNotificationChannel( + NotificationChannel( + CALL_NOTIFICATION_CHANNEL_ID, + stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" }, + NotificationManager.IMPORTANCE_HIGH + ) + .apply { + description = stringProvider.getString(R.string.notification_channel_call) + setSound(null, null) + enableLights(true) + lightColor = accentColor + }) + } + + private fun getChannel(channelId: String): NotificationChannel? { + return notificationManager.getNotificationChannel(channelId) + } + + fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? { + val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + return getChannel(notificationChannel) + } + + fun getChannelIdForMessage(noisy: Boolean): String { + return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + } + + fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID + + companion object { + /* ========================================================================================== + * IDs for channels + * ========================================================================================== */ + private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID" + private const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" + private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID" + private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2" + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) + private fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + + fun openSystemSettingsForSilentCategory(activity: Activity) { + activity.startNotificationChannelSettingsIntent(SILENT_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForNoisyCategory(activity: Activity) { + activity.startNotificationChannelSettingsIntent(NOISY_NOTIFICATION_CHANNEL_ID) + } + + fun openSystemSettingsForCallCategory(activity: Activity) { + activity.startNotificationChannelSettingsIntent(CALL_NOTIFICATION_CHANNEL_ID) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/debug/DebugNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/debug/DebugNotification.kt new file mode 100644 index 0000000000..37f33e1188 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/debug/DebugNotification.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.debug + +fun CharSequence.annotateForDebug(@Suppress("UNUSED_PARAMETER") prefix: Int): CharSequence { + return this // "$prefix-$this" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt new file mode 100755 index 0000000000..b359f540f8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories + +import android.app.Notification +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug +import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory +import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +class NotificationFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val notificationChannels: NotificationChannels, + private val stringProvider: StringProvider, + private val buildMeta: BuildMeta, + private val pendingIntentFactory: PendingIntentFactory, + private val markAsReadActionFactory: MarkAsReadActionFactory, + private val quickReplyActionFactory: QuickReplyActionFactory, +) { + /** + * Create a notification for a Room. + */ + fun createMessagesListNotification( + messageStyle: NotificationCompat.MessagingStyle, + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + // Build the pending intent for when the notification is clicked + val openIntent = when { + threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId) + else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId) + } + + val smallIcon = R.drawable.ic_notification + + val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(roomInfo.isUpdated) + .setWhen(lastMessageTimestamp) + // MESSAGING_STYLE sets title and content for API 16 and above devices. + .setStyle(messageStyle) + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // ID of the corresponding shortcut, for conversation features under API 30+ + .setShortcutId(roomInfo.roomId.value) + // Title for API < 16 devices. + .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1)) + // Content for API < 16 devices. + .setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2)) + // Number of new notifications for API <24 (M and below) devices. + .setSubText( + stringProvider.getQuantityString( + R.plurals.notification_new_messages_for_room, + messageStyle.messages.size, + messageStyle.messages.size + ).annotateForDebug(3) + ) + // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) + // devices and all Wear devices. But we want a custom grouping, so we specify the groupID + .setGroup(roomInfo.sessionId.value) + // In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + // Set primary color (important for Wear 2.0 Notifications). + .setColor(accentColor) + // Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for + // 'importance' which is set in the NotificationChannel. The integers representing + // 'priority' are different from 'importance', so make sure you don't mix them. + .apply { + if (roomInfo.shouldBing) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + + // Add actions and notification intents + // Mark room as read + addAction(markAsReadActionFactory.create(roomInfo)) + // Quick reply + if (!roomInfo.hasSmartReplyError) { + addAction(quickReplyActionFactory.create(roomInfo, threadId)) + } + if (openIntent != null) { + setContentIntent(openIntent) + } + if (largeIcon != null) { + setLargeIcon(largeIcon) + } + setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) + } + .setTicker(tickerText.annotateForDebug(4)) + .build() + } + + fun createRoomInvitationNotification( + inviteNotifiableEvent: InviteNotifiableEvent + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = R.drawable.ic_notification + val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + .setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5)) + .setContentText(inviteNotifiableEvent.description.annotateForDebug(6)) + .setGroup(inviteNotifiableEvent.sessionId.value) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + .setColor(accentColor) + // TODO removed for now, will be added back later +// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) +// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) + .apply { + // Build the pending intent for when the notification is clicked + setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId)) + + if (inviteNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + setDeleteIntent( + pendingIntentFactory.createDismissInvitePendingIntent( + inviteNotifiableEvent.sessionId, + inviteNotifiableEvent.roomId, + ) + ) + setAutoCancel(true) + } + .build() + } + + fun createSimpleEventNotification( + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = R.drawable.ic_notification + + val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) + .setContentText(simpleNotifiableEvent.description.annotateForDebug(8)) + .setGroup(simpleNotifiableEvent.sessionId.value) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + .setColor(accentColor) + .setAutoCancel(true) + .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId)) + .apply { + if (simpleNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + setAutoCancel(true) + } + .build() + } + + fun createFallbackNotification( + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = R.drawable.ic_notification + + val channelId = notificationChannels.getChannelIdForMessage(false) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) + .setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8)) + .setGroup(fallbackNotifiableEvent.sessionId.value) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .setSmallIcon(smallIcon) + .setColor(accentColor) + .setAutoCancel(true) + .setWhen(fallbackNotifiableEvent.timestamp) + // Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite + // and the user won't have access to the room yet, resulting in an error screen. + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId)) + .setDeleteIntent( + pendingIntentFactory.createDismissEventPendingIntent( + fallbackNotifiableEvent.sessionId, + fallbackNotifiableEvent.roomId, + fallbackNotifiableEvent.eventId + ) + ) + .apply { + priority = NotificationCompat.PRIORITY_LOW + setAutoCancel(true) + } + .build() + } + + /** + * Create the summary notification. + */ + fun createSummaryListNotification( + currentUser: MatrixUser, + style: NotificationCompat.InboxStyle?, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification { + val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) + val smallIcon = R.drawable.ic_notification + val channelId = notificationChannels.getChannelIdForMessage(noisy) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + // used in compat < N, after summary is built based on child notifications + .setWhen(lastMessageTimestamp) + .setStyle(style) + .setContentTitle(currentUser.userId.value.annotateForDebug(9)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setSmallIcon(smallIcon) + // set content text to support devices running API level < 24 + .setContentText(compatSummary.annotateForDebug(10)) + .setGroup(currentUser.userId.value) + // set this notification as the summary for the group + .setGroupSummary(true) + .setColor(accentColor) + .apply { + if (noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + /* + vectorPreferences.getNotificationRingTone()?.let { + setSound(it) + } + */ + setLights(accentColor, 500, 500) + } else { + // compat + priority = NotificationCompat.PRIORITY_LOW + } + } + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId)) + .setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId)) + .build() + } + + fun createDiagnosticNotification(): Notification { + return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) + .setContentTitle(buildMeta.applicationName) + .setContentText(stringProvider.getString(R.string.notification_test_push_notification_content)) + .setSmallIcon(R.drawable.ic_notification) + .setLargeIcon(getBitmap(R.drawable.element_logo_green)) + .setColor(ContextCompat.getColor(context, R.color.notification_accent_color)) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setContentIntent(pendingIntentFactory.createTestPendingIntent()) + .build() + } + + private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? { + val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null + val canvas = Canvas() + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + canvas.setBitmap(bitmap) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(canvas) + return bitmap + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt new file mode 100644 index 0000000000..2fe02a8958 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.PendingIntentCompat +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.intent.IntentProvider +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +class PendingIntentFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val intentProvider: IntentProvider, + private val clock: SystemClock, + private val actionIds: NotificationActionIds, +) { + fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? { + return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null) + } + + fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? { + return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null) + } + + fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { + return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId) + } + + private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? { + val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) + return PendingIntent.getActivity( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissSummary + intent.data = createIgnoredUri("deleteSummary/$sessionId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createDismissRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissRoom + intent.data = createIgnoredUri("deleteRoom/$sessionId/$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createDismissInvitePendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissInvite + intent.data = createIgnoredUri("deleteInvite/$sessionId/$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.dismissEvent + intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId/$eventId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value) + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createTestPendingIntent(): PendingIntent? { + val testActionIntent = Intent(context, TestNotificationReceiver::class.java) + testActionIntent.action = actionIds.diagnostic + return PendingIntent.getBroadcast( + context, + 0, + testActionIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent { + val intent = intentProvider.getInviteListIntent(sessionId) + return PendingIntentCompat.getActivity(context, 0, intent, 0, false) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt new file mode 100644 index 0000000000..06ef22247e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories.action + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +class AcceptInvitationActionFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + // offer to type a quick accept button + fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action { + val sessionId = inviteNotifiableEvent.sessionId.value + val roomId = inviteNotifiableEvent.roomId.value + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.join + intent.data = createIgnoredUri("acceptInvite/$sessionId/$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val pendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder( + R.drawable.vector_notification_accept_invitation, + stringProvider.getString(R.string.notification_invitation_action_join), + pendingIntent + ).build() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt new file mode 100644 index 0000000000..0dcf4bb326 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories.action + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.NotificationConfig +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +class MarkAsReadActionFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? { + if (!NotificationConfig.supportMarkAsReadAction) return null + val sessionId = roomInfo.sessionId.value + val roomId = roomInfo.roomId.value + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.markRoomRead + intent.data = createIgnoredUri("markRead/$sessionId/$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val pendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Action.Builder( + R.drawable.ic_material_done_all_white, + stringProvider.getString(R.string.notification_room_action_mark_as_read), + pendingIntent + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt new file mode 100644 index 0000000000..a5f72acfb9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories.action + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.RemoteInput +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.NotificationConfig +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +class QuickReplyActionFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? { + if (!NotificationConfig.supportQuickReplyAction) return null + val sessionId = roomInfo.sessionId + val roomId = roomInfo.roomId + return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent -> + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() + + NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(R.string.notification_room_action_quick_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .build() + } + } + + /* + * Direct reply is new in Android N, and Android already handles the UI, so the right pending intent + * here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver, + * which runs on the UI thread. It also works without unlocking, making the process really fluid for the user. + * However, for Android devices running Marshmallow and below (API level 23 and below), + * it will be more appropriate to use an activity. Since you have to provide your own UI. + */ + private fun buildQuickReplyIntent( + sessionId: SessionId, + roomId: RoomId, + threadId: ThreadId?, + ): PendingIntent? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.smartReply + intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) + } + + PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + // PendingIntents attached to actions with remote inputs must be mutable + PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + ) + } else { + null + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt new file mode 100644 index 0000000000..75e1cdaf99 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.factories.action + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +class RejectInvitationActionFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? { + val sessionId = inviteNotifiableEvent.sessionId.value + val roomId = inviteNotifiableEvent.roomId.value + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.reject + intent.data = createIgnoredUri("rejectInvite/$sessionId/$roomId") + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + val pendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Action.Builder( + R.drawable.vector_notification_reject_invitation, + stringProvider.getString(R.string.notification_invitation_action_reject), + pendingIntent + ).build() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt new file mode 100644 index 0000000000..fe6cc537d0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Used for notifications with events that couldn't be retrieved or decrypted, so we don't know their contents. + * These are created separately from message notifications, so they can be displayed differently. + */ +data class FallbackNotifiableEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + override val description: String?, + override val canBeReplaced: Boolean, + override val isRedacted: Boolean, + override val isUpdated: Boolean, + val timestamp: Long, +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt new file mode 100644 index 0000000000..6b562a434e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +data class InviteNotifiableEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + override val canBeReplaced: Boolean, + val roomName: String?, + val noisy: Boolean, + val title: String?, + override val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt new file mode 100644 index 0000000000..ddfbbf8b07 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import java.io.Serializable + +/** + * Parent interface for all events which can be displayed as a Notification. + */ +sealed interface NotifiableEvent : Serializable { + val sessionId: SessionId + val roomId: RoomId + val eventId: EventId + val editedEventId: EventId? + val description: String? + + // Used to know if event should be replaced with the one coming from eventstream + val canBeReplaced: Boolean + val isRedacted: Boolean + val isUpdated: Boolean +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt new file mode 100644 index 0000000000..57a3eb45aa --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.model + +import android.net.Uri +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.currentRoomId +import io.element.android.services.appnavstate.api.currentSessionId +import io.element.android.services.appnavstate.api.currentThreadId + +data class NotifiableMessageEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + override val canBeReplaced: Boolean, + val noisy: Boolean, + val timestamp: Long, + val senderName: String?, + val senderId: String?, + val body: String?, + // We cannot use Uri? type here, as that could trigger a + // NotSerializableException when persisting this to storage + val imageUriString: String?, + val threadId: ThreadId?, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent { + + val type: String = EventType.MESSAGE + override val description: String = body ?: "" + val title: String = senderName ?: "" + + // TODO EAx The image has to be downloaded and expose using the file provider. + // Example of value from Element Android: + // content://im.vector.app.debug.mx-sdk.fileprovider/downloads/downloads/816abf76d806c768760568952b1862c8/F/72c33edd23dee3b95f4d5a18aa25fa54/image.png + val imageUri: Uri? + get() = imageUriString?.let { Uri.parse(it) } +} + +/** + * Used to check if a notification should be ignored based on the current app and navigation state. + */ +fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean { + val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false + return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) { + null -> false + else -> appNavigationState.isInForeground + && sessionId == currentSessionId + && roomId == currentRoomId + && (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt new file mode 100644 index 0000000000..f252765530 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +data class SimpleNotifiableEvent( + override val sessionId: SessionId, + override val roomId: RoomId, + override val eventId: EventId, + override val editedEventId: EventId?, + val noisy: Boolean, + val title: String, + override val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override var canBeReplaced: Boolean, + override val isRedacted: Boolean = false, + override val isUpdated: Boolean = false +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt new file mode 100644 index 0000000000..e1fd17332e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/permission/NotificationPermissionManager.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +// TODO EAx move +class NotificationPermissionManager @Inject constructor( + private val sdkIntProvider: BuildVersionSdkIntProvider, + @ApplicationContext private val context: Context, +) { + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun isPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + /* + fun eventuallyRequestPermission( + activity: Activity, + requestPermissionLauncher: ActivityResultLauncher<Array<String>>, + showRationale: Boolean = true, + ignorePreference: Boolean = false, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + // if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return + checkPermissions( + listOf(Manifest.permission.POST_NOTIFICATIONS), + activity, + activityResultLauncher = requestPermissionLauncher, + if (showRationale) R.string.permissions_rationale_msg_notification else 0 + ) + } + */ + + fun eventuallyRevokePermission( + activity: Activity, + ) { + if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return + activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt new file mode 100644 index 0000000000..3ad848aeb4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.push + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.push.impl.PushersManager +import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("PushHandler", pushLoggerTag) + +@ContributesBinding(AppScope::class) +class DefaultPushHandler @Inject constructor( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val notifiableEventResolver: NotifiableEventResolver, + private val defaultPushDataStore: DefaultPushDataStore, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushClientSecret: PushClientSecret, + private val actionIds: NotificationActionIds, + @ApplicationContext private val context: Context, + private val buildMeta: BuildMeta, + private val matrixAuthenticationService: MatrixAuthenticationService, +) : PushHandler { + + private val coroutineScope = CoroutineScope(SupervisorJob()) + + // UI handler + private val mUIHandler by lazy { + Handler(Looper.getMainLooper()) + } + + /** + * Called when message is received. + * + * @param pushData the data received in the push. + */ + override suspend fun handle(pushData: PushData) { + Timber.tag(loggerTag.value).d("## handling pushData") + + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## pushData: $pushData") + } + + defaultPushDataStore.incrementPushCounter() + + // Diagnostic Push + if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + val intent = Intent(actionIds.push) + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + return + } + + mUIHandler.post { + coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } + } + } + + /** + * Internal receive method. + * + * @param pushData Object containing message data. + */ + private suspend fun handleInternal(pushData: PushData) { + try { + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## handleInternal() : $pushData") + } else { + Timber.tag(loggerTag.value).d("## handleInternal()") + } + + val clientSecret = pushData.clientSecret + val userId = if (clientSecret == null) { + // Should not happen. In this case, restore default session + null + } else { + // Get userId from client secret + pushClientSecret.getUserIdFromSecret(clientSecret) + } ?: run { + matrixAuthenticationService.getLatestSessionId() + } + + if (userId == null) { + Timber.w("Unable to get a session") + return + } + + val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) + + if (notifiableEvent == null) { + Timber.w("Unable to get a notification data") + return + } + + val userPushStore = userPushStoreFactory.create(userId) + if (!userPushStore.areNotificationEnabledForDevice()) { + // TODO We need to check if this is an incoming call + Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") + return + } + + defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } catch (e: Exception) { + Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt new file mode 100644 index 0000000000..02bd7850e9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + + +import retrofit2.http.Body +import retrofit2.http.POST + +internal interface PushGatewayAPI { + /** + * Ask the Push Gateway to send a push to the current device. + * + * Ref: https://matrix.org/docs/spec/push_gateway/r0.1.1#post-matrix-push-v1-notify + */ + @POST(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH + "notify") + suspend fun notify(@Body body: PushGatewayNotifyBody): PushGatewayNotifyResponse +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt new file mode 100644 index 0000000000..5cd46f873d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +object PushGatewayConfig { + // Push Gateway + const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/" +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt new file mode 100644 index 0000000000..7adedfcfd2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayDevice( + /** + * Required. The app_id given when the pusher was created. + */ + @SerialName("app_id") + val appId: String, + /** + * Required. The pushkey given when the pusher was created. + */ + @SerialName("pushkey") + val pushKey: String +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt new file mode 100644 index 0000000000..b7649f6800 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotification( + @SerialName("event_id") + val eventId: String, + + /** + * Required. This is an array of devices that the notification should be sent to. + */ + @SerialName("devices") + val devices: List<PushGatewayDevice> +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt new file mode 100644 index 0000000000..ce41d2d83e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyBody( + /** + * Required. Information about the push notification + */ + @SerialName("notification") + val notification: PushGatewayNotification +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..7130e38d6e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import javax.inject.Inject + +class PushGatewayNotifyRequest @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) { + data class Params( + val url: String, + val appId: String, + val pushKey: String, + val eventId: EventId + ) + + suspend fun execute(params: Params) { + val sygnalApi = retrofitFactory.create( + params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) + ) + .create(PushGatewayAPI::class.java) + + val response = sygnalApi.notify( + PushGatewayNotifyBody( + PushGatewayNotification( + eventId = params.eventId.value, + devices = listOf( + PushGatewayDevice( + params.appId, + params.pushKey + ) + ) + ) + ) + ) + + if (response.rejectedPushKeys.contains(params.pushKey)) { + throw PushGatewayFailure.PusherRejected + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt new file mode 100644 index 0000000000..13d9cbad1d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class PushGatewayNotifyResponse( + @SerialName("rejected") + val rejectedPushKeys: List<String> +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt new file mode 100644 index 0000000000..22faa91453 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.push.api.store.PushDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store") + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPushDataStore @Inject constructor( + @ApplicationContext private val context: Context, +) : PushDataStore { + private val pushCounter = intPreferencesKey("push_counter") + + override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences -> + preferences[pushCounter] ?: 0 + } + + suspend fun incrementPushCounter() { + context.dataStore.edit { settings -> + val currentCounterValue = settings[pushCounter] ?: 0 + settings[pushCounter] = currentCounterValue + 1 + } + } +} diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml new file mode 100644 index 0000000000..e9b119c969 --- /dev/null +++ b/libraries/push/impl/src/main/res/drawable-xxhdpi/element_logo_green.xml @@ -0,0 +1,22 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="64dp" + android:height="64dp" + android:viewportWidth="64" + android:viewportHeight="64"> + <path + android:pathData="M23.04,3.84C23.04,1.7192 24.7593,0 26.88,0C41.0185,0 52.48,11.4615 52.48,25.6C52.48,27.7208 50.7608,29.44 48.64,29.44C46.5193,29.44 44.8,27.7208 44.8,25.6C44.8,15.7031 36.777,7.68 26.88,7.68C24.7593,7.68 23.04,5.9608 23.04,3.84Z" + android:fillColor="#0DBD8B" + android:fillType="evenOdd"/> + <path + android:pathData="M40.96,60.16C40.96,62.2808 39.2407,64 37.12,64C22.9815,64 11.52,52.5385 11.52,38.4C11.52,36.2792 13.2392,34.56 15.36,34.56C17.4807,34.56 19.2,36.2792 19.2,38.4C19.2,48.2969 27.223,56.32 37.12,56.32C39.2407,56.32 40.96,58.0392 40.96,60.16Z" + android:fillColor="#0DBD8B" + android:fillType="evenOdd"/> + <path + android:pathData="M3.84,40.96C1.7192,40.96 -0,39.2407 -0,37.12C-0,22.9815 11.4615,11.52 25.6,11.52C27.7208,11.52 29.44,13.2392 29.44,15.36C29.44,17.4807 27.7208,19.2 25.6,19.2C15.7031,19.2 7.68,27.223 7.68,37.12C7.68,39.2407 5.9608,40.96 3.84,40.96Z" + android:fillColor="#0DBD8B" + android:fillType="evenOdd"/> + <path + android:pathData="M60.16,23.04C62.2808,23.04 64,24.7593 64,26.88C64,41.0185 52.5385,52.48 38.4,52.48C36.2792,52.48 34.56,50.7608 34.56,48.64C34.56,46.5193 36.2792,44.8 38.4,44.8C48.2969,44.8 56.32,36.777 56.32,26.88C56.32,24.7593 58.0392,23.04 60.16,23.04Z" + android:fillColor="#0DBD8B" + android:fillType="evenOdd"/> +</vector> diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png new file mode 100755 index 0000000000..1f3132a3f2 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_material_done_all_white.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000000..a86508b71b Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png new file mode 100755 index 0000000000..eb2be25187 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_accept_invitation.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png new file mode 100755 index 0000000000..4af4ae634b Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_quick_reply.png differ diff --git a/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png new file mode 100755 index 0000000000..51b4401ca0 Binary files /dev/null and b/libraries/push/impl/src/main/res/drawable-xxhdpi/vector_notification_reject_invitation.png differ diff --git a/libraries/push/impl/src/main/res/values-cs/translations.xml b/libraries/push/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..84525a427e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Hovor"</string> + <string name="notification_channel_listening_for_events">"Naslouchání událostem"</string> + <string name="notification_channel_noisy">"Hlasitá oznámení"</string> + <string name="notification_channel_silent">"Tichá oznámení"</string> + <string name="notification_inline_reply_failed">"** Nepodařilo se odeslat - otevřete prosím místnost"</string> + <string name="notification_invitation_action_join">"Vstoupit"</string> + <string name="notification_invitation_action_reject">"Odmítnout"</string> + <string name="notification_invite_body">"Vás pozval(a) do chatu"</string> + <string name="notification_new_messages">"Nové zprávy"</string> + <string name="notification_reaction_body">"Reagoval(a) s %1$s"</string> + <string name="notification_room_action_mark_as_read">"Označit jako přečtené"</string> + <string name="notification_room_invite_body">"Vás pozval(a) do místnosti"</string> + <string name="notification_sender_me">"Já"</string> + <string name="notification_test_push_notification_content">"Prohlížíte si oznámení! Klikněte na mě!"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s v %2$s a %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d zpráva"</item> + <item quantity="few">"%1$s: %2$d zprávy"</item> + <item quantity="other">"%1$s: %2$d zpráv"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d oznámení"</item> + <item quantity="few">"%d oznámení"</item> + <item quantity="other">"%d oznámení"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d pozvánka"</item> + <item quantity="few">"%d pozvánky"</item> + <item quantity="other">"%d pozvánek"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d nová zpráva"</item> + <item quantity="few">"%d nové zprávy"</item> + <item quantity="other">"%d nových zpráv"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d nepřečtená oznámená zpráva"</item> + <item quantity="few">"%d nepřečtené oznámené zprávy"</item> + <item quantity="other">"%d nepřečtených oznámených zpráv"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d místnost"</item> + <item quantity="few">"%d místnosti"</item> + <item quantity="other">"%d místností"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Vyberte, jak chcete přijímat oznámení"</string> + <string name="push_distributor_background_sync_android">"Synchronizace na pozadí"</string> + <string name="push_distributor_firebase_android">"Služby Google"</string> + <string name="push_no_valid_google_play_services_apk_android">"Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně."</string> + <string name="notification_fallback_content">"Oznámení"</string> + <string name="notification_room_action_quick_reply">"Rychlá odpověď"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..a2ae094731 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Anruf"</string> + <string name="notification_channel_listening_for_events">"Warte auf Ereignisse"</string> + <string name="notification_channel_noisy">"Laute Benachrichtigungen"</string> + <string name="notification_channel_silent">"Stumme Benachrichtigungen"</string> + <string name="notification_inline_reply_failed">"** Senden fehlgeschlagen - bitte Raum öffnen"</string> + <string name="notification_invitation_action_join">"Beitreten"</string> + <string name="notification_invitation_action_reject">"Ablehnen"</string> + <string name="notification_invite_body">"Hat dich eingeladen"</string> + <string name="notification_new_messages">"Neue Nachrichten"</string> + <string name="notification_reaction_body">"Reagierte mit %1$s"</string> + <string name="notification_room_action_mark_as_read">"Als gelesen markieren"</string> + <string name="notification_room_invite_body">"Hat dich eingeladen, dem Raum beizutreten"</string> + <string name="notification_sender_me">"Ich"</string> + <string name="notification_test_push_notification_content">"Du siehst die Benachrichtigung an! Klick mich an!"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s und %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s und %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d Nachricht"</item> + <item quantity="other">"%1$s: %2$d Nachrichten"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d Mitteilung"</item> + <item quantity="other">"%d Mitteilungen"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d Einladung"</item> + <item quantity="other">"%d Einladungen"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d neue Nachricht"</item> + <item quantity="other">"%d neue Nachrichten"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d ungelesene benachrichtigte Nachricht"</item> + <item quantity="other">"%d ungelesene benachrichtigte Nachrichten"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d Raum"</item> + <item quantity="other">"%d Räume"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Auswählen, wie Benachrichtigungen empfangen werden sollen"</string> + <string name="push_distributor_background_sync_android">"Hintergrundsynchronisation"</string> + <string name="push_distributor_firebase_android">"Google-Dienste"</string> + <string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string> + <string name="notification_fallback_content">"Mitteilung"</string> + <string name="notification_room_action_quick_reply">"Schnellantwort"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..138a2ce8e1 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_fallback_content">"Notificación"</string> + <string name="notification_room_action_quick_reply">"Respuesta rápida"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-fr/translations.xml b/libraries/push/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..6e6374e8f2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Appel"</string> + <string name="notification_channel_listening_for_events">"À l\'écoute d\'événements"</string> + <string name="notification_channel_noisy">"Notifications bruyantes"</string> + <string name="notification_channel_silent">"Notifications silencieuses"</string> + <string name="notification_inline_reply_failed">"** Échec d\'envoi - veuillez ouvrir le salon"</string> + <string name="notification_invitation_action_join">"Rejoindre"</string> + <string name="notification_invitation_action_reject">"Refuser"</string> + <string name="notification_invite_body">"Vous a invité à discuter"</string> + <string name="notification_new_messages">"Nouveaux messages"</string> + <string name="notification_reaction_body">"A réagi avec %1$s"</string> + <string name="notification_room_action_mark_as_read">"Marquer comme lu"</string> + <string name="notification_room_invite_body">"Vous a invité à rejoindre le salon"</string> + <string name="notification_sender_me">"Moi"</string> + <string name="notification_test_push_notification_content">"Vous êtes en train de consulter la notification ! Cliquez-moi !"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s et %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s dans %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s dans %2$s et %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d message"</item> + <item quantity="other">"%1$s: %2$d messages"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d notification"</item> + <item quantity="other">"%d notifications"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d invitation"</item> + <item quantity="other">"%d invitations"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d nouveau message"</item> + <item quantity="other">"%d nouveaux messages"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d message notifié non lu"</item> + <item quantity="other">"%d messages notifiés non lus"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d conversation"</item> + <item quantity="other">"%d conversations"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Choisissez comment recevoir les notifications"</string> + <string name="push_distributor_background_sync_android">"Synchronisation en arrière-plan"</string> + <string name="push_distributor_firebase_android">"Services Google"</string> + <string name="push_no_valid_google_play_services_apk_android">"Aucun service Google Play valide n\'a été trouvé. Les notifications peuvent ne pas fonctionner correctement."</string> + <string name="notification_fallback_content">"Notification"</string> + <string name="notification_room_action_quick_reply">"Réponse rapide"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-it/translations.xml b/libraries/push/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..92c5c06f9b --- /dev/null +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_fallback_content">"Notifica"</string> + <string name="notification_room_action_quick_reply">"Risposta rapida"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-ro/translations.xml b/libraries/push/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..8cabe414a2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Apel"</string> + <string name="notification_channel_listening_for_events">"Ascultare evenimente"</string> + <string name="notification_channel_noisy">"Notificări zgomotoase"</string> + <string name="notification_channel_silent">"Notificări silențioase"</string> + <string name="notification_inline_reply_failed">"** Trimiterea eșuată - vă rugăm să deschideți camera"</string> + <string name="notification_invitation_action_join">"Alăturați-vă"</string> + <string name="notification_invitation_action_reject">"Respingeți"</string> + <string name="notification_invite_body">"V-a invitat la o discuție"</string> + <string name="notification_new_messages">"Mesaje noi"</string> + <string name="notification_reaction_body">"A reacționat cu %1$s"</string> + <string name="notification_room_action_mark_as_read">"Marcați ca citit"</string> + <string name="notification_room_invite_body">"V-a invitat să vă alăturați camerei"</string> + <string name="notification_sender_me">"Eu"</string> + <string name="notification_test_push_notification_content">"Vizualizați o notificare! Faceți clic pe mine!"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s și %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s în %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s în %2$s și %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d mesaj"</item> + <item quantity="other">"%1$s: %2$d mesaje"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d notificare"</item> + <item quantity="other">"%d notificări"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d invitație"</item> + <item quantity="other">"%d invitații"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d mesaj nou"</item> + <item quantity="other">"%d mesaje noi"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d mesaj notificat necitit"</item> + <item quantity="other">"%d mesaje notificate necitite"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d cameră"</item> + <item quantity="other">"%d camere"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Alegeți modul de primire a notificărilor"</string> + <string name="push_distributor_background_sync_android">"Sincronizare în fundal"</string> + <string name="push_distributor_firebase_android">"Servicii Google"</string> + <string name="push_no_valid_google_play_services_apk_android">"Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect."</string> + <string name="notification_fallback_content">"Notificare"</string> + <string name="notification_room_action_quick_reply">"Raspuns rapid"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values-sk/translations.xml b/libraries/push/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..16eb94685d --- /dev/null +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Zavolať"</string> + <string name="notification_channel_listening_for_events">"Počúvanie udalostí"</string> + <string name="notification_channel_noisy">"Hlasité oznámenia"</string> + <string name="notification_channel_silent">"Tiché oznámenia"</string> + <string name="notification_inline_reply_failed">"** Nepodarilo sa odoslať - prosím otvorte miestnosť"</string> + <string name="notification_invitation_action_join">"Pripojiť sa"</string> + <string name="notification_invitation_action_reject">"Zamietnuť"</string> + <string name="notification_invite_body">"Vás pozval/a na konverzáciu"</string> + <string name="notification_new_messages">"Nové správy"</string> + <string name="notification_reaction_body">"Reagoval/a s %1$s"</string> + <string name="notification_room_action_mark_as_read">"Označiť ako prečítané"</string> + <string name="notification_room_invite_body">"Vás pozval do miestnosti"</string> + <string name="notification_sender_me">"Ja"</string> + <string name="notification_test_push_notification_content">"Prezeráte si oznámenie! Kliknite na mňa!"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s v %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s v %2$s a %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d správa"</item> + <item quantity="few">"%1$s: %2$d správy"</item> + <item quantity="other">"%1$s: %2$d správ"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d oznámenie"</item> + <item quantity="few">"%d oznámenia"</item> + <item quantity="other">"%d oznámení"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d pozvánka"</item> + <item quantity="few">"%d pozvánky"</item> + <item quantity="other">"%d pozvánok"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d nová správa"</item> + <item quantity="few">"%d nové správy"</item> + <item quantity="other">"%d nových správ"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d neprečítaná oznámená správa"</item> + <item quantity="few">"%d neprečítané oznámené správy"</item> + <item quantity="other">"%d neprečítaných oznámených správ"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d miestnosť"</item> + <item quantity="few">"%d miestnosti"</item> + <item quantity="other">"%d miestností"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Vyberte spôsob prijímania oznámení"</string> + <string name="push_distributor_background_sync_android">"Synchronizácia na pozadí"</string> + <string name="push_distributor_firebase_android">"Služby Google"</string> + <string name="push_no_valid_google_play_services_apk_android">"Nenašli sa žiadne platné služby Google Play. Oznámenia nemusia fungovať správne."</string> + <string name="notification_fallback_content">"Oznámenie"</string> + <string name="notification_room_action_quick_reply">"Rýchla odpoveď"</string> +</resources> diff --git a/libraries/push/impl/src/main/res/values/colors.xml b/libraries/push/impl/src/main/res/values/colors.xml new file mode 100644 index 0000000000..6e04238a1a --- /dev/null +++ b/libraries/push/impl/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <!-- TODO EAx --> + <color name="notification_accent_color">#368BD6</color> + +</resources> diff --git a/libraries/push/impl/src/main/res/values/dimens.xml b/libraries/push/impl/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..ce2fee2015 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/dimens.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + + <dimen name="profile_avatar_size">50dp</dimen> + +</resources> diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..987728304a --- /dev/null +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="notification_channel_call">"Call"</string> + <string name="notification_channel_listening_for_events">"Listening for events"</string> + <string name="notification_channel_noisy">"Noisy notifications"</string> + <string name="notification_channel_silent">"Silent notifications"</string> + <string name="notification_inline_reply_failed">"** Failed to send - please open room"</string> + <string name="notification_invitation_action_join">"Join"</string> + <string name="notification_invitation_action_reject">"Reject"</string> + <string name="notification_invite_body">"Invited you to chat"</string> + <string name="notification_new_messages">"New Messages"</string> + <string name="notification_reaction_body">"Reacted with %1$s"</string> + <string name="notification_room_action_mark_as_read">"Mark as read"</string> + <string name="notification_room_invite_body">"Invited you to join the room"</string> + <string name="notification_sender_me">"Me"</string> + <string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string> + <string name="notification_ticker_text_dm">"%1$s: %2$s"</string> + <string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string> + <string name="notification_unread_notified_messages_and_invitation">"%1$s and %2$s"</string> + <string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string> + <string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s and %3$s"</string> + <plurals name="notification_compat_summary_line_for_room"> + <item quantity="one">"%1$s: %2$d message"</item> + <item quantity="other">"%1$s: %2$d messages"</item> + </plurals> + <plurals name="notification_compat_summary_title"> + <item quantity="one">"%d notification"</item> + <item quantity="other">"%d notifications"</item> + </plurals> + <plurals name="notification_invitations"> + <item quantity="one">"%d invitation"</item> + <item quantity="other">"%d invitations"</item> + </plurals> + <plurals name="notification_new_messages_for_room"> + <item quantity="one">"%d new message"</item> + <item quantity="other">"%d new messages"</item> + </plurals> + <plurals name="notification_unread_notified_messages"> + <item quantity="one">"%d unread notified message"</item> + <item quantity="other">"%d unread notified messages"</item> + </plurals> + <plurals name="notification_unread_notified_messages_in_room_rooms"> + <item quantity="one">"%d room"</item> + <item quantity="other">"%d rooms"</item> + </plurals> + <string name="push_choose_distributor_dialog_title_android">"Choose how to receive notifications"</string> + <string name="push_distributor_background_sync_android">"Background synchronization"</string> + <string name="push_distributor_firebase_android">"Google Services"</string> + <string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string> + <string name="notification_fallback_content">"Notification"</string> + <string name="notification_room_action_quick_reply">"Quick reply"</string> +</resources> diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt new file mode 100644 index 0000000000..28b001ca28 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeOutdatedEventDetector +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.services.appnavstate.test.aNavigationState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test + +private val NOT_VIEWING_A_ROOM = aNavigationState() +private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) +private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) + +class NotifiableEventProcessorTest { + + private val outdatedDetector = FakeOutdatedEventDetector() + + @Test + fun `given simple events when processing then keep simple events`() { + val events = listOf( + aSimpleNotifiableEvent(eventId = AN_EVENT_ID), + aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2) + ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ProcessedEvent.Type.KEEP to events[1] + ) + ) + } + + @Test + fun `given redacted simple event when processing then remove redaction event`() { + val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION)) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0] + ) + ) + } + + @Test + fun `given invites are not auto accepted when processing then keep invitation events`() { + val events = listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID_2) + ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ProcessedEvent.Type.KEEP to events[1] + ) + ) + } + + @Test + fun `given out of date message event when processing then removes message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsOutOfDate(events[0]) + + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given in date message event when processing then keep message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) + events.forEach { outdatedDetector.givenEventIsOutOfDate(it) } + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given viewing the same thread timeline when processing thread message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) + events.forEach { outdatedDetector.givenEventIsOutOfDate(it) } + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to events[0], + ) + ) + } + + @Test + fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) + outdatedDetector.givenEventIsInDate(events[0]) + val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) + + val result = eventProcessor.process(events, renderedEvents = emptyList()) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.KEEP to events[0], + ) + ) + } + + @Test + fun `given events are different to rendered events when processing then removes difference`() { + val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)) + val renderedEvents = listOf<ProcessedEvent<NotifiableEvent>>( + ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]), + ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2)) + ) + val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) + + val result = eventProcessor.process(events, renderedEvents = renderedEvents) + + assertThat(result).isEqualTo( + listOfProcessedEvents( + ProcessedEvent.Type.REMOVE to renderedEvents[1].event, + ProcessedEvent.Type.KEEP to renderedEvents[0].event + ) + ) + } + + private fun listOfProcessedEvents(vararg event: Pair<ProcessedEvent.Type, NotifiableEvent>) = event.map { + ProcessedEvent(it.first, it.second) + } + + private fun createProcessor( + isInForeground: Boolean = false, + navigationState: NavigationState + ): NotifiableEventProcessor { + return NotifiableEventProcessor( + outdatedDetector.instance, + FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt new file mode 100644 index 0000000000..f9b63eb9dc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.cache.CircularCache +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import org.junit.Test + +class NotificationEventQueueTest { + + private val seenIdsCache = CircularCache.create<EventId>(5) + + @Test + fun `given events when redacting some then marks matching event ids as redacted`() { + val queue = givenQueue( + listOf( + aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1")), + aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2")), + anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3")), + aSimpleNotifiableEvent(eventId = EventId("\$kept-id")), + ) + ) + + queue.markRedacted(listOf(EventId("\$redacted-id-1"), EventId("\$redacted-id-2"), EventId("\$redacted-id-3"))) + + assertThat(queue.rawEvents()).isEqualTo( + listOf( + aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1"), isRedacted = true), + aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2"), isRedacted = true), + anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3"), isRedacted = true), + aSimpleNotifiableEvent(eventId = EventId("\$kept-id"), isRedacted = false), + ) + ) + } + + @Test + fun `given invite event when leaving invited room and syncing then removes event`() { + val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + val roomsLeft = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given invite event when joining invited room and syncing then removes event`() { + val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + val joinedRooms = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given message event when leaving message room and syncing then removes event`() { + val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + val roomsLeft = listOf(A_ROOM_ID) + + queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given events when syncing without rooms left or joined ids then does not change the events`() { + val queue = givenQueue( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID) + ) + ) + + queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList()) + + assertThat(queue.rawEvents()).isEqualTo( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + anInviteNotifiableEvent(roomId = A_ROOM_ID) + ) + ) + } + + @Test + fun `given events then is not empty`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + assertThat(queue.isEmpty()).isFalse() + } + + @Test + fun `given no events then is empty`() { + val queue = givenQueue(emptyList()) + + assertThat(queue.isEmpty()).isTrue() + } + + @Test + fun `given events when clearing and adding then removes previous events and adds only new events`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + queue.clearAndAdd(listOf(anInviteNotifiableEvent())) + + assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent())) + } + + @Test + fun `when clearing then is empty`() { + val queue = givenQueue(listOf(aSimpleNotifiableEvent())) + + queue.clear() + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given no events when adding then adds event`() { + val queue = givenQueue(listOf()) + + queue.add(aSimpleNotifiableEvent()) + + assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent())) + } + + @Test + fun `given no events when adding already seen event then ignores event`() { + val queue = givenQueue(listOf()) + val notifiableEvent = aSimpleNotifiableEvent() + seenIdsCache.put(notifiableEvent.eventId) + + queue.add(notifiableEvent) + + assertThat(queue.rawEvents()).isEmpty() + } + + @Test + fun `given replaceable event when adding event with same id then updates existing event`() { + val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true) + val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(replaceableEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `given non replaceable event when adding event with same id then ignores event`() { + val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false) + val updatedEvent = nonReplaceableEvent.copy(title = "updated title") + val queue = givenQueue(listOf(nonReplaceableEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent)) + } + + @Test + fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() { + val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$id-to-edit")) + val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(editedEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() { + val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$0"), editedEventId = EventId("\$id-to-edit")) + val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) + val queue = givenQueue(listOf(editedEvent)) + + queue.add(updatedEvent) + + assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) + } + + @Test + fun `when clearing membership notification then removes invite events with matching room id`() { + val queue = givenQueue( + listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID) + ) + ) + + queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + + assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + } + + @Test + fun `when clearing messages for room then removes message events with matching room id`() { + val queue = givenQueue( + listOf( + anInviteNotifiableEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID) + ) + ) + + queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + + assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) + } + + private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000000..18d8870ac3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeAndroidNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +class NotificationFactoryTest { + + private val androidNotificationFactory = FakeAndroidNotificationFactory() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + + private val notificationFactory = NotificationFactory( + androidNotificationFactory.instance, + roomGroupMessageCreator.instance, + summaryGroupMessageCreator.instance + ) + + @Test + fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = androidNotificationFactory.givenCreateRoomInvitationNotificationFor(AN_INVITATION_EVENT) + val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT)) + + val result = roomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = A_ROOM_ID.value, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + ) + } + + @Test + fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT)) + + val result = missingEventRoomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Removed( + key = A_ROOM_ID.value + ) + ) + ) + } + + @Test + fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = androidNotificationFactory.givenCreateSimpleInvitationNotificationFor(A_SIMPLE_EVENT) + val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT)) + + val result = roomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + ) + } + + @Test + fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT)) + + val result = missingEventRoomInvitation.toNotifications() + + assertThat(result).isEqualTo( + listOf( + OneShotNotification.Removed( + key = AN_EVENT_ID.value + ) + ) + ) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), events, A_ROOM_ID + ) + val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT))) + + val result = roomWithMessage.toNotifications( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + ) + + assertThat(result).isEqualTo(listOf(expectedNotification)) + } + + @Test + fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { + val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT)) + val emptyRoom = mapOf(A_ROOM_ID to events) + + val result = emptyRoom.toNotifications( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + ) + + assertThat(result).isEqualTo( + listOf( + RoomNotification.Removed( + roomId = A_ROOM_ID + ) + ) + ) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { + val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) + + val result = redactedRoom.toNotifications( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + ) + + assertThat(result).isEqualTo( + listOf( + RoomNotification.Removed( + roomId = A_ROOM_ID + ) + ) + ) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( + notificationFactory + ) { + val roomWithRedactedMessage = mapOf( + A_ROOM_ID to listOf( + ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), + ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) + ) + ) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + withRedactedRemoved, + A_ROOM_ID, + ) + + val result = roomWithRedactedMessage.toNotifications( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL) + ) + + assertThat(result).isEqualTo(listOf(expectedNotification)) + } +} + +fun <T> testWith(receiver: T, block: suspend T.() -> Unit) { + runTest { + receiver.block() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt new file mode 100644 index 0000000000..57f28e72db --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import org.junit.Test + +class NotificationIdProviderTest { + @Test + fun `test notification id provider`() { + val sut = NotificationIdProvider() + val offsetForASessionId = 305410 + assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0) + assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1) + assertThat(sut.getRoomEventNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 2) + assertThat(sut.getRoomInvitationNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 3) + // Check that value will be different for another sessionId + assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isNotEqualTo(sut.getSummaryNotificationId(A_SESSION_ID_2)) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt new file mode 100644 index 0000000000..80875406c7 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationFactory +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private const val MY_USER_DISPLAY_NAME = "display-name" +private const val MY_USER_AVATAR_URL = "avatar-url" +private const val USE_COMPLETE_NOTIFICATION_FORMAT = true + +private val AN_EVENT_LIST = listOf<ProcessedEvent<NotifiableEvent>>() +private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList()) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) +private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed +private val A_NOTIFICATION = mockk<Notification>() +private val MESSAGE_META = RoomNotification.Message.Meta( + summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false +) +private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + +class NotificationRendererTest { + + private val notificationDisplayer = FakeNotificationDisplayer() + private val notificationFactory = FakeNotificationFactory() + private val notificationIdProvider = NotificationIdProvider() + + private val notificationRenderer = NotificationRenderer( + notificationIdProvider = notificationIdProvider, + notificationDisplayer = notificationDisplayer.instance, + notificationFactory = notificationFactory.instance, + ) + + @Test + fun `given no notifications when rendering then cancels summary notification`() = runTest { + givenNoNotifications() + + renderEventsAsNotifications() + + notificationDisplayer.verifySummaryCancelled() + notificationDisplayer.verifyNoOtherInteractions() + } + + @Test + fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() = runTest { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() = runTest { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { + givenNotifications( + roomNotifications = listOf( + RoomNotification.Message( + A_NOTIFICATION, + MESSAGE_META + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() = runTest { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() = runTest { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { + givenNotifications( + simpleNotifications = listOf( + OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = AN_EVENT_ID.value) + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() = runTest { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) + } + } + + @Test + fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() = runTest { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { + givenNotifications( + simpleNotifications = listOf( + OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = A_ROOM_ID.value) + ) + ) + ) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) + showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) + } + } + + private suspend fun renderEventsAsNotifications() { + notificationRenderer.render( + MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), + useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, + eventsToProcess = AN_EVENT_LIST + ) + } + + private fun givenNoNotifications() { + givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) + } + + private fun givenNotifications( + roomNotifications: List<RoomNotification> = emptyList(), + invitationNotifications: List<OneShotNotification> = emptyList(), + simpleNotifications: List<OneShotNotification> = emptyList(), + fallbackNotifications: List<OneShotNotification> = emptyList(), + useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, + summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION + ) { + notificationFactory.givenNotificationsFor( + groupedEvents = A_PROCESSED_EVENTS, + matrixUser = MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), + useCompleteNotificationFormat = useCompleteNotificationFormat, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + summaryNotification = summaryNotification + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt new file mode 100644 index 0000000000..c046e1253f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeAndroidNotificationFactory { + val instance = mockk<NotificationFactory>() + + fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { + val mockNotification = mockk<Notification>() + every { instance.createRoomInvitationNotification(event) } returns mockNotification + return mockNotification + } + + fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification { + val mockNotification = mockk<Notification>() + every { instance.createSimpleEventNotification(event) } returns mockNotification + return mockNotification + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..9af681490a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.NotificationIdProvider +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder + +class FakeNotificationDisplayer { + val instance = mockk<NotificationDisplayer>(relaxed = true) + + fun verifySummaryCancelled() { + verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } + } + + fun verifyNoOtherInteractions() { + confirmVerified(instance) + } + + fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { + verifyOrder { verifyBlock(instance) } + verifyNoOtherInteractions() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt new file mode 100644 index 0000000000..60b9e10c3d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents +import io.element.android.libraries.push.impl.notifications.NotificationFactory +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryNotification +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationFactory { + val instance = mockk<NotificationFactory>() + + fun givenNotificationsFor( + groupedEvents: GroupedNotificationEvents, + matrixUser: MatrixUser, + useCompleteNotificationFormat: Boolean, + roomNotifications: List<RoomNotification>, + invitationNotifications: List<OneShotNotification>, + simpleNotifications: List<OneShotNotification>, + fallbackNotifications: List<OneShotNotification>, + summaryNotification: SummaryNotification + ) { + with(instance) { + coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications + every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications + every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications + every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications + + every { + createSummaryNotification( + matrixUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + useCompleteNotificationFormat + ) + } returns summaryNotification + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt new file mode 100644 index 0000000000..03bf7e8491 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeOutdatedEventDetector.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.push.impl.notifications.OutdatedEventDetector +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeOutdatedEventDetector { + val instance = mockk<OutdatedEventDetector>() + + fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns true + } + + fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns false + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt new file mode 100644 index 0000000000..b896737e6f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.mockk.coEvery +import io.mockk.mockk + +class FakeRoomGroupMessageCreator { + + val instance = mockk<RoomGroupMessageCreator>() + + fun givenCreatesRoomMessageFor( + matrixUser: MatrixUser, + events: List<NotifiableMessageEvent>, + roomId: RoomId, + ): RoomNotification.Message { + val mockMessage = mockk<RoomNotification.Message>() + coEvery { + instance.createRoomMessage( + currentUser = matrixUser, + events = events, + roomId = roomId, + ) + } returns mockMessage + return mockMessage + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..fc7b0553eb --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator +import io.mockk.mockk + +class FakeSummaryGroupMessageCreator { + + val instance = mockk<SummaryGroupMessageCreator>() +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt new file mode 100644 index 0000000000..9a998abf43 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.libraries.push.impl.notifications.fixtures + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent + +fun aSimpleNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + type: String? = null, + isRedacted: Boolean = false, + canBeReplaced: Boolean = false, + editedEventId: EventId? = null +) = SimpleNotifiableEvent( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + editedEventId = editedEventId, + noisy = false, + title = "title", + description = "description", + type = type, + timestamp = 0, + soundName = null, + canBeReplaced = canBeReplaced, + isRedacted = isRedacted +) + +fun anInviteNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + isRedacted: Boolean = false +) = InviteNotifiableEvent( + sessionId = sessionId, + eventId = eventId, + roomId = roomId, + roomName = "a room name", + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = isRedacted +) + +fun aNotifiableMessageEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + threadId: ThreadId? = null, + isRedacted: Boolean = false +) = NotifiableMessageEvent( + sessionId = sessionId, + eventId = eventId, + editedEventId = null, + noisy = false, + timestamp = 0, + senderName = "sender-name", + senderId = "sending-id", + body = "message-body", + roomId = roomId, + threadId = threadId, + roomName = "room-name", + roomIsDirect = false, + canBeReplaced = false, + isRedacted = isRedacted, + imageUriString = null +) diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts new file mode 100644 index 0000000000..9fccadb9be --- /dev/null +++ b/libraries/push/test/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.push.test" +} + +dependencies { + api(projects.libraries.push.api) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt new file mode 100644 index 0000000000..1531d2df48 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.push.test.notifications + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager + +class FakeNotificationDrawerManager : NotificationDrawerManager { + private val clearMemberShipNotificationForSessionCallsCount = mutableMapOf<String, Int>() + private val clearMemberShipNotificationForRoomCallsCount = mutableMapOf<String, Int>() + + override fun clearMembershipNotificationForSession(sessionId: SessionId) { + clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value } + } + + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + val key = getMembershipNotificationKey(sessionId, roomId) + clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value } + } + + fun getClearMembershipNotificationForSessionCount(sessionId: SessionId): Int { + return clearMemberShipNotificationForRoomCallsCount[sessionId.value] ?: 0 + } + + fun getClearMembershipNotificationForRoomCount(sessionId: SessionId, roomId: RoomId): Int { + val key = getMembershipNotificationKey(sessionId, roomId) + return clearMemberShipNotificationForRoomCallsCount[key] ?: 0 + } + + private fun getMembershipNotificationKey(sessionId: SessionId, roomId: RoomId): String { + return "$sessionId-$roomId" + } +} diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts new file mode 100644 index 0000000000..f22fc18735 --- /dev/null +++ b/libraries/pushproviders/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushproviders.api" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt new file mode 100644 index 0000000000..7eda80fed9 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.api + +data class Distributor( + val value: String, + val name: String, +) diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt new file mode 100644 index 0000000000..bcfa723469 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.api + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Represent parsed data that the app has received from a Push content. + * + * @property eventId The Event Id. + * @property roomId The Room Id. + * @property unread Number of unread message. + * @property clientSecret data used when the pusher was configured, to be able to determine the session. + */ +data class PushData( + val eventId: EventId, + val roomId: RoomId, + val unread: Int?, + val clientSecret: String?, +) diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt new file mode 100644 index 0000000000..9677a63dfc --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.api + +interface PushHandler { + suspend fun handle(pushData: PushData) +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt new file mode 100644 index 0000000000..d71cd9ec3f --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.api + +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * This is the main API for this module. + */ +interface PushProvider { + /** + * Allow to sort providers, from lower index to higher index. + */ + val index: Int + + /** + * User friendly name. + */ + val name: String + + fun getDistributors(): List<Distributor> + + /** + * Register the pusher to the homeserver. + */ + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) + + /** + * Unregister the pusher. + */ + suspend fun unregister(matrixClient: MatrixClient) + + /** + * Attempt to troubleshoot the push provider. + */ + suspend fun troubleshoot(): Result<Unit> +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt new file mode 100644 index 0000000000..2529e4bb96 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.api + +import io.element.android.libraries.matrix.api.MatrixClient + +interface PusherSubscriber { + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) +} diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md new file mode 100644 index 0000000000..204ac6dd19 --- /dev/null +++ b/libraries/pushproviders/firebase/README.md @@ -0,0 +1,7 @@ +# Firebase + +## Configuration + +In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. + +To be able to change the values in the file `firebase.xml` from this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `firebase.xml` files. diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts new file mode 100644 index 0000000000..96faa3197b --- /dev/null +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.pushproviders.firebase" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.pushproviders.api) + + api(platform(libs.google.firebase.bom)) + api("com.google.firebase:firebase-messaging-ktx") + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml new file mode 100644 index 0000000000..540f0e9bbe --- /dev/null +++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="google_app_id" translatable="false">1:912726360885:android:def0a4e454042e9b00427c</string> +</resources> diff --git a/libraries/pushproviders/firebase/src/main/AndroidManifest.xml b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..fb1ed56229 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +~ Copyright (c) 2023 New Vector Ltd +~ +~ 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"> + <application> + <!-- Firebase components --> + <meta-data + android:name="firebase_analytics_collection_deactivated" + android:value="true" /> + <service + android:name="io.element.android.libraries.pushproviders.firebase.VectorFirebaseMessagingService" + android:exported="false"> + <intent-filter> + <action android:name="com.google.firebase.MESSAGING_EVENT" /> + </intent-filter> + </service> + </application> +</manifest> diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/EnsureFcmTokenIsRetrievedUseCase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/EnsureFcmTokenIsRetrievedUseCase.kt new file mode 100644 index 0000000000..d557aa0334 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/EnsureFcmTokenIsRetrievedUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import javax.inject.Inject + +// TODO +class EnsureFcmTokenIsRetrievedUseCase @Inject constructor( +// private val unifiedPushHelper: UnifiedPushHelper, +// private val fcmHelper: FcmHelper, + // private val activeSessionHolder: ActiveSessionHolder, +) { + +// fun execute(pushersManager: PushersManager, registerPusher: Boolean) { +// if (unifiedPushHelper.isEmbeddedDistributor()) { +// fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) +// } +// } + + private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) { + /* + TODO EAx + val currentSession = activeSessionHolder.getActiveSession() + val currentPushers = currentSession.pushersService().getPushers() + currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId } + */ + true + } else { + false + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseConfig.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseConfig.kt new file mode 100644 index 0000000000..62081a9e56 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseConfig.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +object FirebaseConfig { + /** + * It is the push gateway for firebase. + * Note: pusher_http_url should have path '/_matrix/push/v1/notify' --> + */ + const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify" + + const val index = 0 + const val name = "Firebase" +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt new file mode 100644 index 0000000000..dc938bd141 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserList +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("FirebaseNewTokenHandler") + +/** + * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. + */ +class FirebaseNewTokenHandler @Inject constructor( + private val pusherSubscriber: PusherSubscriber, + private val sessionStore: SessionStore, + private val userPushStoreFactory: UserPushStoreFactory, + private val matrixAuthenticationService: MatrixAuthenticationService, + private val firebaseStore: FirebaseStore, +) { + suspend fun handle(firebaseToken: String) { + firebaseStore.storeFcmToken(firebaseToken) + // Register the pusher for all the sessions + sessionStore.getAllSessions().toUserList() + .map { SessionId(it) } + .forEach { userId -> + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.getPushProviderName() == FirebaseConfig.name) { + matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> + pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.pusher_http_url) + } + } else { + Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") + } + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParser.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParser.kt new file mode 100644 index 0000000000..e529b7c44c --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParser.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import io.element.android.libraries.pushproviders.api.PushData +import javax.inject.Inject + +class FirebasePushParser @Inject constructor() { + fun parse(message: Map<String, String?>): PushData? { + val pushDataFirebase = PushDataFirebase( + eventId = message["event_id"], + roomId = message["room_id"], + unread = message["unread"]?.toIntOrNull(), + clientSecret = message["cs"], + ) + return pushDataFirebase.toPushData() + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt new file mode 100644 index 0000000000..5d496b39ca --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("FirebasePushProvider") + +@ContributesMultibinding(AppScope::class) +class FirebasePushProvider @Inject constructor( + private val firebaseStore: FirebaseStore, + private val firebaseTroubleshooter: FirebaseTroubleshooter, + private val pusherSubscriber: PusherSubscriber, +) : PushProvider { + override val index = FirebaseConfig.index + override val name = FirebaseConfig.name + + override fun getDistributors(): List<Distributor> { + return listOf(Distributor("Firebase", "Firebase")) + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") + } + pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url) + } + + override suspend fun unregister(matrixClient: MatrixClient) { + val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + } + pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url) + } + + override suspend fun troubleshoot(): Result<Unit> { + return firebaseTroubleshooter.troubleshoot() + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt new file mode 100644 index 0000000000..0342c67462 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import android.content.SharedPreferences +import androidx.core.content.edit +import io.element.android.libraries.di.DefaultPreferences +import javax.inject.Inject + +/** + * This class store the Firebase token in SharedPrefs. + */ +class FirebaseStore @Inject constructor( + @DefaultPreferences private val sharedPrefs: SharedPreferences, +) { + fun getFcmToken(): String? { + return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) + } + + fun storeFcmToken(token: String?) { + sharedPrefs.edit { + putString(PREFS_KEY_FCM_TOKEN, token) + } + } + + companion object { + private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN" + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt new file mode 100644 index 0000000000..f3efba1a16 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.messaging.FirebaseMessaging +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * This class force retrieving and storage of the Firebase token. + */ +class FirebaseTroubleshooter @Inject constructor( + @ApplicationContext private val context: Context, + private val newTokenHandler: FirebaseNewTokenHandler, +) { + suspend fun troubleshoot(): Result<Unit> { + return runCatching { + val token = retrievedFirebaseToken() + newTokenHandler.handle(token) + } + } + + private suspend fun retrievedFirebaseToken(): String { + return suspendCoroutine { continuation -> + // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + if (checkPlayServices(context)) { + try { + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> + continuation.resume(token) + } + .addOnFailureListener { e -> + Timber.e(e, "## retrievedFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } catch (e: Throwable) { + Timber.e(e, "## retrievedFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } else { + val e = Exception("No valid Google Play Services found. Cannot use FCM.") + Timber.e(e) + continuation.resumeWithException(e) + } + } + } + + /** + * Check the device to make sure it has the Google Play Services APK. If + * it doesn't, display a dialog that allows users to download the APK from + * the Google Play Store or enable it in the device's system settings. + */ + private fun checkPlayServices(context: Context): Boolean { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt new file mode 100644 index 0000000000..9dedf9648f --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.pushproviders.api.PushData + +/** + * In this case, the format is: + * <pre> + * { + * "event_id":"$anEventId", + * "room_id":"!aRoomId", + * "unread":"1", + * "prio":"high", + * "cs":"<client_secret>" + * } + * </pre> + * . + */ +data class PushDataFirebase( + val eventId: String?, + val roomId: String?, + var unread: Int?, + val clientSecret: String? +) + +fun PushDataFirebase.toPushData(): PushData? { + val safeEventId = eventId?.let(::EventId) ?: return null + val safeRoomId = roomId?.let(::RoomId) ?: return null + return PushData( + eventId = safeEventId, + roomId = safeRoomId, + unread = unread, + clientSecret = clientSecret, + ) +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt new file mode 100644 index 0000000000..56ac65a338 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.pushproviders.api.PushHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Firebase") + +class VectorFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler + @Inject lateinit var pushParser: FirebasePushParser + @Inject lateinit var pushHandler: PushHandler + + private val coroutineScope = CoroutineScope(SupervisorJob()) + + override fun onCreate() { + super.onCreate() + applicationContext.bindings<VectorFirebaseMessagingServiceBindings>().inject(this) + } + + override fun onNewToken(token: String) { + Timber.tag(loggerTag.value).d("New Firebase token") + coroutineScope.launch { + firebaseNewTokenHandler.handle(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + Timber.tag(loggerTag.value).d("New Firebase message") + coroutineScope.launch { + val pushData = pushParser.parse(message.data) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from Firebase") + } else { + pushHandler.handle(pushData) + } + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceBindings.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceBindings.kt new file mode 100644 index 0000000000..e34d946957 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceBindings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface VectorFirebaseMessagingServiceBindings { + fun inject(service: VectorFirebaseMessagingService) +} diff --git a/libraries/pushproviders/firebase/src/main/res/values/firebase.xml b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml new file mode 100644 index 0000000000..163717db91 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="default_web_client_id" translatable="false">912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com</string> + <string name="firebase_database_url" translatable="false">https://vector-alpha.firebaseio.com</string> + <string name="gcm_defaultSenderId" translatable="false">912726360885</string> + <string name="google_api_key" translatable="false">AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c</string> + <string name="google_crash_reporting_api_key" translatable="false">AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c</string> + <string name="google_storage_bucket" translatable="false">vector-alpha.appspot.com</string> + <string name="project_id" translatable="false">vector-alpha</string> +</resources> diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml new file mode 100644 index 0000000000..f793ba93f9 --- /dev/null +++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="google_app_id" translatable="false">1:912726360885:android:e17435e0beb0303000427c</string> +</resources> diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml new file mode 100644 index 0000000000..d563b43d05 --- /dev/null +++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="google_app_id" translatable="false">1:912726360885:android:d097de99a4c23d2700427c</string> +</resources> diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt new file mode 100644 index 0000000000..0f5f1bb38f --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.tests.testutils.assertThrowsInDebug +import org.junit.Test + +class FirebasePushParserTest { + private val validData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = "a-secret" + ) + + @Test + fun `test edge cases Firebase`() { + val pushParser = FirebasePushParser() + // Empty Json + assertThat(pushParser.parse(emptyMap())).isNull() + // Bad Json + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("unread", "str"))).isEqualTo(validData.copy(unread = null)) + // Extra data + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("extra", "5"))).isEqualTo(validData) + } + + @Test + fun `test Firebase format`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA)).isEqualTo(validData) + } + + @Test + fun `test empty roomId`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", null))).isNull() + assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "")) } + } + + @Test + fun `test invalid roomId`() { + val pushParser = FirebasePushParser() + assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "aRoomId:domain")) } + } + + @Test + fun `test empty eventId`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", null))).isNull() + assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "")) } + } + + @Test + fun `test invalid eventId`() { + val pushParser = FirebasePushParser() + assertThrowsInDebug { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "anEventId")) } + } + + companion object { + private val FIREBASE_PUSH_DATA = mapOf( + "event_id" to AN_EVENT_ID.value, + "room_id" to A_ROOM_ID.value, + "unread" to "1", + "prio" to "high", + "cs" to "a-secret", + ) + } +} + +private fun Map<String, String?>.mutate(key: String, value: String?): Map<String, String?> { + return toMutableMap().apply { put(key, value) } +} diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts new file mode 100644 index 0000000000..abc4c0babc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.22" +} + +android { + namespace = "io.element.android.libraries.pushproviders.unifiedpush" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.services.toolbox.api) + + implementation(projects.libraries.network) + implementation(platform(libs.network.okhttp.bom)) + implementation("com.squareup.okhttp3:okhttp") + implementation(libs.network.retrofit) + + implementation(libs.serialization.json) + + // UnifiedPush library + api(libs.unifiedpush) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..719733ab3e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +~ Copyright (c) 2023 New Vector Ltd +~ +~ 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" + xmlns:tools="http://schemas.android.com/tools"> + <application> + <receiver + android:name=".VectorUnifiedPushMessagingReceiver" + android:enabled="true" + android:exported="true" + tools:ignore="ExportedReceiver"> + <intent-filter> + <action android:name="org.unifiedpush.android.connector.MESSAGE" /> + <action android:name="org.unifiedpush.android.connector.UNREGISTERED" /> + <action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" /> + <action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" /> + <action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED" /> + </intent-filter> + </receiver> + <receiver + android:name=".KeepInternalDistributor" + android:enabled="true" + android:exported="false"> + <intent-filter> + <!-- + This action is checked to track installed and uninstalled distributors. + We declare it to keep the background sync as an internal + unifiedpush distributor. + --> + <action android:name="org.unifiedpush.android.distributor.REGISTER" /> + </intent-filter> + </receiver> + </application> +</manifest> diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/GuardServiceStarter.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/GuardServiceStarter.kt new file mode 100644 index 0000000000..cd807b6d55 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/GuardServiceStarter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +interface GuardServiceStarter { + fun start() {} + fun stop() {} +} + +@ContributesBinding(AppScope::class) +class NoopGuardServiceStarter @Inject constructor() : GuardServiceStarter diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/KeepInternalDistributor.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/KeepInternalDistributor.kt new file mode 100644 index 0000000000..8e90b53077 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/KeepInternalDistributor.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * UnifiedPush lib tracks an action to check installed and uninstalled distributors. + * We declare it to keep the background sync as an internal unifiedpush distributor. + * This class is used to declare this action. + */ +class KeepInternalDistributor : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) {} +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt new file mode 100644 index 0000000000..f092d0167c --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.pushproviders.api.PushData +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * In this case, the format is: + * <pre> + * { + * "notification":{ + * "event_id":"$anEventId", + * "room_id":"!aRoomId", + * "counts":{ + * "unread":1 + * }, + * "prio":"high" + * } + * } + * </pre> + * . + */ +@Serializable +data class PushDataUnifiedPush( + val notification: PushDataUnifiedPushNotification? = null +) + +@Serializable +data class PushDataUnifiedPushNotification( + @SerialName("event_id") val eventId: String? = null, + @SerialName("room_id") val roomId: String? = null, + @SerialName("counts") var counts: PushDataUnifiedPushCounts? = null, +) + +@Serializable +data class PushDataUnifiedPushCounts( + @SerialName("unread") val unread: Int? = null +) + +fun PushDataUnifiedPush.toPushData(clientSecret: String): PushData? { + val safeEventId = notification?.eventId?.let(::EventId) ?: return null + val safeRoomId = notification.roomId?.let(::RoomId) ?: return null + return PushData( + eventId = safeEventId, + roomId = safeRoomId, + unread = notification.counts?.unread, + clientSecret = clientSecret + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..d42405ef9c --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import org.unifiedpush.android.connector.UnifiedPush +import javax.inject.Inject + +class RegisterUnifiedPushUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val pusherSubscriber: PusherSubscriber, + private val unifiedPushStore: UnifiedPushStore, +) { + + sealed interface RegisterUnifiedPushResult { + object Success : RegisterUnifiedPushResult + object NeedToAskUserForDistributor : RegisterUnifiedPushResult + object Error : RegisterUnifiedPushResult + } + + suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { + val distributorValue = distributor.value + if (distributorValue.isNotEmpty()) { + saveAndRegisterApp(distributorValue, clientSecret) + val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error + val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error + pusherSubscriber.registerPusher(matrixClient, endpoint, gateway) + return RegisterUnifiedPushResult.Success + } + + // TODO Below should never happen? + if (UnifiedPush.getDistributor(context).isNotEmpty()) { + registerApp(clientSecret) + return RegisterUnifiedPushResult.Success + } + + val distributors = UnifiedPush.getDistributors(context) + + return if (distributors.size == 1) { + saveAndRegisterApp(distributors.first(), clientSecret) + RegisterUnifiedPushResult.Success + } else { + RegisterUnifiedPushResult.NeedToAskUserForDistributor + } + } + + private fun saveAndRegisterApp(distributor: String, clientSecret: String) { + UnifiedPush.saveDistributor(context, distributor) + registerApp(clientSecret) + } + + private fun registerApp(clientSecret: String) { + UnifiedPush.registerApp(context = context, instance = clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushConfig.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushConfig.kt new file mode 100644 index 0000000000..aad00c5bd7 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushConfig.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +object UnifiedPushConfig { + /** + * It is the push gateway for UnifiedPush. + * Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify' + */ + const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" + + const val index = 1 + const val name = "UnifiedPush" +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt new file mode 100644 index 0000000000..3b2abaf38a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.URL +import javax.inject.Inject + +class UnifiedPushGatewayResolver @Inject constructor( + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) { + suspend fun getGateway(endpoint: String): String? { + val gateway = UnifiedPushConfig.default_push_gateway_http_url + val url = URL(endpoint) + val port = if (url.port != -1) { ":${url.port}" } else { "" } + val customBase = "${url.protocol}://${url.host}${port}" + val customUrl = "$customBase/_matrix/push/v1/notify" + Timber.i("Testing $customUrl") + try { + return withContext(coroutineDispatchers.io) { + val api = retrofitFactory.create(customBase) + .create(UnifiedPushApi::class.java) + try { + val discoveryResponse = api.discover() + if (discoveryResponse.unifiedpush.gateway == "matrix") { + Timber.d("Using custom gateway") + return@withContext customUrl + } + } catch (throwable: Throwable) { + Timber.tag("UnifiedPushHelper").e(throwable) + } + return@withContext gateway + } + } catch (e: Throwable) { + Timber.d(e, "Cannot try custom gateway") + } + return gateway + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000000..1a6cdb90c0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler") + +/** + * Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider. + */ +class UnifiedPushNewGatewayHandler @Inject constructor( + private val pusherSubscriber: PusherSubscriber, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushClientSecret: PushClientSecret, + private val matrixAuthenticationService: MatrixAuthenticationService, +) { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) { + // Register the pusher for the session with this client secret, if is it using UnifiedPush. + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also { + Timber.w("Unable to retrieve session") + } + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.getPushProviderName() == UnifiedPushConfig.name) { + matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } + } else { + Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt new file mode 100644 index 0000000000..f68cd8542b --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.pushproviders.api.PushData +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class UnifiedPushParser @Inject constructor() { + private val json by lazy { Json { ignoreUnknownKeys = true } } + + fun parse(message: ByteArray, clientSecret: String): PushData? { + return tryOrNull { json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData(clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt new file mode 100644 index 0000000000..6f74986ae5 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import org.unifiedpush.android.connector.UnifiedPush +import javax.inject.Inject + +@ContributesMultibinding(AppScope::class) +class UnifiedPushProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val pushClientSecret: PushClientSecret, +) : PushProvider { + override val index = UnifiedPushConfig.index + override val name = UnifiedPushConfig.name + + override fun getDistributors(): List<Distributor> { + val distributors = UnifiedPush.getDistributors(context) + return distributors.mapNotNull { + if (it == context.packageName) { + // Exclude self + null + } else { + Distributor(it, context.getApplicationLabel(it)) + } + } + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret) + } + + override suspend fun unregister(matrixClient: MatrixClient) { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + unRegisterUnifiedPushUseCase.execute(clientSecret) + } + + override suspend fun troubleshoot(): Result<Unit> { + TODO("Not yet implemented") + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt new file mode 100644 index 0000000000..d063dfce3e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.DefaultPreferences +import javax.inject.Inject + +class UnifiedPushStore @Inject constructor( + @ApplicationContext val context: Context, + @DefaultPreferences private val defaultPrefs: SharedPreferences, +) { + /** + * Retrieves the UnifiedPush Endpoint. + * + * @param clientSecret the client secret, to identify the session + * @return the UnifiedPush Endpoint or null if not received + */ + fun getEndpoint(clientSecret: String): String? { + return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) + } + + /** + * Store UnifiedPush Endpoint to the SharedPrefs. + * + * @param endpoint the endpoint to store + * @param clientSecret the client secret, to identify the session + */ + fun storeUpEndpoint(endpoint: String?, clientSecret: String) { + defaultPrefs.edit { + putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint) + } + } + + /** + * Retrieves the Push Gateway. + * + * @param clientSecret the client secret, to identify the session + * @return the Push Gateway or null if not defined + */ + fun getPushGateway(clientSecret: String): String? { + return defaultPrefs.getString(PREFS_PUSH_GATEWAY + clientSecret, null) + } + + /** + * Store Push Gateway to the SharedPrefs. + * + * @param gateway the push gateway to store + * @param clientSecret the client secret, to identify the session + */ + fun storePushGateway(gateway: String?, clientSecret: String) { + defaultPrefs.edit { + putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) + } + } + + companion object { + private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" + private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..4efaacbf3a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import io.element.android.libraries.di.ApplicationContext +import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber +import javax.inject.Inject + +class UnregisterUnifiedPushUseCase @Inject constructor( + @ApplicationContext private val context: Context, + //private val pushDataStore: PushDataStore, + private val unifiedPushStore: UnifiedPushStore, + private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, +) { + + suspend fun execute(clientSecret: String /*pushersManager: PushersManager?*/) { + //val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + //pushDataStore.setFdroidSyncBackgroundMode(mode) + try { + unifiedPushStore.getEndpoint(clientSecret)?.let { + Timber.d("Removing $it") + // TODO pushersManager?.unregisterPusher(it) + } + } catch (e: Exception) { + Timber.d(e, "Probably unregistering a non existing pusher") + } + unifiedPushStore.storeUpEndpoint(null, clientSecret) + unifiedPushStore.storePushGateway(null, clientSecret) + UnifiedPush.unregisterApp(context) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt new file mode 100644 index 0000000000..e2006f61cc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import android.content.Intent +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.pushproviders.api.PushHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.MessagingReceiver +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("VectorUnifiedPushMessagingReceiver") + +class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { + @Inject lateinit var pushParser: UnifiedPushParser + @Inject lateinit var pushHandler: PushHandler + @Inject lateinit var guardServiceStarter: GuardServiceStarter + @Inject lateinit var unifiedPushStore: UnifiedPushStore + @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver + @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler + + private val coroutineScope = CoroutineScope(SupervisorJob()) + + override fun onReceive(context: Context, intent: Intent) { + context.applicationContext.bindings<VectorUnifiedPushMessagingReceiverBindings>().inject(this) + super.onReceive(context, intent) + } + + /** + * Called when message is received. The message contains the full POST body of the push message. + * + * @param context the Android context + * @param message the message + * @param instance connection, for multi-account + */ + override fun onMessage(context: Context, message: ByteArray, instance: String) { + Timber.tag(loggerTag.value).d("New message") + coroutineScope.launch { + val pushData = pushParser.parse(message, instance) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush") + } else { + pushHandler.handle(pushData) + } + } + } + + /** + * Called when a new endpoint is to be used for sending push messages. + * You should send the endpoint to your application server and sync for missing notifications. + */ + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") + // If the endpoint has changed + // or the gateway has changed + if (unifiedPushStore.getEndpoint(instance) != endpoint) { + unifiedPushStore.storeUpEndpoint(endpoint, instance) + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + unifiedPushStore.storePushGateway(gateway, instance) + gateway?.let { pushGateway -> + newGatewayHandler.handle(endpoint, pushGateway, instance) + } + } + } else { + Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") + } + guardServiceStarter.stop() + } + + /** + * Called when the registration is not possible, eg. no network. + */ + override fun onRegistrationFailed(context: Context, instance: String) { + Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance") + /* + Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + pushDataStore.setFdroidSyncBackgroundMode(mode) + guardServiceStarter.start() + */ + } + + /** + * Called when this application is unregistered from receiving push messages. + */ + override fun onUnregistered(context: Context, instance: String) { + Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered") + TODO() + /* + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + pushDataStore.setFdroidSyncBackgroundMode(mode) + guardServiceStarter.start() + runBlocking { + try { + pushersManager.unregisterPusher(unifiedPushHelper.getEndpointOrToken().orEmpty()) + } catch (e: Exception) { + Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher") + } + } + */ + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt new file mode 100644 index 0000000000..f7c6394d49 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface VectorUnifiedPushMessagingReceiverBindings { + fun inject(receiver: VectorUnifiedPushMessagingReceiver) +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryResponse.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryResponse.kt new file mode 100644 index 0000000000..4669ddc821 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiscoveryResponse( + @SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush() +) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryUnifiedPush.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryUnifiedPush.kt new file mode 100644 index 0000000000..3370ad48dc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryUnifiedPush.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiscoveryUnifiedPush( + @SerialName("gateway") val gateway: String = "" +) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/UnifiedPushApi.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/UnifiedPushApi.kt new file mode 100644 index 0000000000..cd6cd7440e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/UnifiedPushApi.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush.network + +import retrofit2.http.GET + +interface UnifiedPushApi { + @GET("_matrix/push/v1/notify") + suspend fun discover(): DiscoveryResponse +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt new file mode 100644 index 0000000000..bbccc92581 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.tests.testutils.assertThrowsInDebug +import org.junit.Test + +class UnifiedPushParserTest { + private val aClientSecret = "a-client-secret" + private val validData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = aClientSecret + ) + + @Test + fun `test edge cases UnifiedPush`() { + val pushParser = UnifiedPushParser() + // Empty string + assertThat(pushParser.parse("".toByteArray(), aClientSecret)).isNull() + // Empty Json + assertThat(pushParser.parse("{}".toByteArray(), aClientSecret)).isNull() + // Bad Json + assertThat(pushParser.parse("ABC".toByteArray(), aClientSecret)).isNull() + } + + @Test + fun `test UnifiedPush format`() { + val pushParser = UnifiedPushParser() + assertThat(pushParser.parse(UNIFIED_PUSH_DATA.toByteArray(), aClientSecret)).isEqualTo(validData) + } + + @Test + fun `test empty roomId`() { + val pushParser = UnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.replace(A_ROOM_ID.value, "").toByteArray(), aClientSecret) + } + } + + @Test + fun `test invalid roomId`() { + val pushParser = UnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(A_ROOM_ID.value, "aRoomId:domain"), aClientSecret) + } + } + + @Test + fun `test empty eventId`() { + val pushParser = UnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, ""), aClientSecret) + } + } + + @Test + fun `test invalid eventId`() { + val pushParser = UnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, "anEventId"), aClientSecret) + } + } + + companion object { + private val UNIFIED_PUSH_DATA = + "{\"notification\":{\"event_id\":\"$AN_EVENT_ID\",\"room_id\":\"$A_ROOM_ID\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" + // TODO Check client secret format? + } +} + +private fun String.mutate(oldValue: String, newValue: String): ByteArray { + return replace(oldValue, newValue).toByteArray() +} diff --git a/libraries/pushstore/api/build.gradle.kts b/libraries/pushstore/api/build.gradle.kts new file mode 100644 index 0000000000..fdfd794c2e --- /dev/null +++ b/libraries/pushstore/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushstore.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt new file mode 100644 index 0000000000..28577ba3f8 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.api + +/** + * Store data related to push about a user. + */ +interface UserPushStore { + suspend fun getPushProviderName(): String? + suspend fun setPushProviderName(value: String) + suspend fun getCurrentRegisteredPushKey(): String? + suspend fun setCurrentRegisteredPushKey(value: String) + + suspend fun areNotificationEnabledForDevice(): Boolean + suspend fun setNotificationEnabledForDevice(enabled: Boolean) + + /** + * Return true if Pin code is disabled, or if user set the settings to see full notification content. + */ + fun useCompleteNotificationFormat(): Boolean + + suspend fun reset() +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt new file mode 100644 index 0000000000..52e4596ca0 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.api + +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Store data related to push about a user. + */ +interface UserPushStoreFactory { + fun create(userId: SessionId): UserPushStore +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt new file mode 100644 index 0000000000..dbdd22ce07 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.api.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushClientSecret { + /** + * To call when registering a pusher. It will return the existing secret or create a new one. + */ + suspend fun getSecretForUser(userId: SessionId): String + + /** + * To call when receiving a push containing a client secret. + * Return null if not found. + */ + suspend fun getUserIdFromSecret(clientSecret: String): SessionId? + + /** + * To call when the user signs out. + */ + suspend fun resetSecretForUser(userId: SessionId) +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt new file mode 100644 index 0000000000..128302d5c0 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.api.clientsecret + +interface PushClientSecretFactory { + fun create(): String +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt new file mode 100644 index 0000000000..e2bd5a6084 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.api.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId + +interface PushClientSecretStore { + suspend fun storeSecret(userId: SessionId, clientSecret: String) + suspend fun getSecret(userId: SessionId): String? + suspend fun resetSecret(userId: SessionId) + suspend fun getUserIdFromSecret(clientSecret: String): SessionId? +} diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts new file mode 100644 index 0000000000..dca5c82a4d --- /dev/null +++ b/libraries/pushstore/impl/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.push.pushstore.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.sessionStorage.api) + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt new file mode 100644 index 0000000000..a84fe2ea69 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = UserPushStoreFactory::class) +class DefaultUserPushStoreFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val sessionObserver: SessionObserver, +) : UserPushStoreFactory, SessionListener { + init { + observeSessions() + } + + // We can have only one class accessing a single data store, so keep a cache of them. + private val cache = mutableMapOf<SessionId, UserPushStore>() + override fun create(userId: SessionId): UserPushStore { + return cache.getOrPut(userId) { + UserPushStoreDataStore( + context = context, + userId = userId + ) + } + } + + private fun observeSessions() { + sessionObserver.addListener(this) + } + + override suspend fun onSessionCreated(userId: String) { + // Nothing to do + } + + override suspend fun onSessionDeleted(userId: String) { + // Delete the store + create(SessionId(userId)).reset() + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt new file mode 100644 index 0000000000..56867a6584 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.UserPushStore +import kotlinx.coroutines.flow.first + +/** + * Store data related to push about a user. + */ +class UserPushStoreDataStore( + private val context: Context, + userId: SessionId, +) : UserPushStore { + private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_store_$userId") + private val pushProviderName = stringPreferencesKey("pushProviderName") + private val currentPushKey = stringPreferencesKey("currentPushKey") + private val notificationEnabled = booleanPreferencesKey("notificationEnabled") + + override suspend fun getPushProviderName(): String? { + return context.dataStore.data.first()[pushProviderName] + } + + override suspend fun setPushProviderName(value: String) { + context.dataStore.edit { + it[pushProviderName] = value + } + } + + override suspend fun getCurrentRegisteredPushKey(): String? { + return context.dataStore.data.first()[currentPushKey] + } + + override suspend fun setCurrentRegisteredPushKey(value: String) { + context.dataStore.edit { + it[currentPushKey] = value + } + } + + override suspend fun areNotificationEnabledForDevice(): Boolean { + return context.dataStore.data.first()[notificationEnabled].orTrue() + } + + override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { + context.dataStore.edit { + it[notificationEnabled] = enabled + } + } + + override fun useCompleteNotificationFormat(): Boolean { + return true + } + + override suspend fun reset() { + context.dataStore.edit { + it.clear() + } + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt new file mode 100644 index 0000000000..4e6e718a60 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory +import java.util.UUID +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PushClientSecretFactoryImpl @Inject constructor() : PushClientSecretFactory { + override fun create(): String { + return UUID.randomUUID().toString() + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt new file mode 100644 index 0000000000..ca0ed14e33 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class PushClientSecretImpl @Inject constructor( + private val pushClientSecretFactory: PushClientSecretFactory, + private val pushClientSecretStore: PushClientSecretStore, +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + val existingSecret = pushClientSecretStore.getSecret(userId) + if (existingSecret != null) { + return existingSecret + } + val newSecret = pushClientSecretFactory.create() + pushClientSecretStore.storeSecret(userId, newSecret) + return newSecret + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return pushClientSecretStore.getUserIdFromSecret(clientSecret) + } + + override suspend fun resetSecretForUser(userId: SessionId) { + pushClientSecretStore.resetSecret(userId) + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt new file mode 100644 index 0000000000..92ba2bfe1e --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "push_client_secret_store") + +@ContributesBinding(AppScope::class) +class PushClientSecretStoreDataStore @Inject constructor( + @ApplicationContext private val context: Context, +) : PushClientSecretStore { + override suspend fun storeSecret(userId: SessionId, clientSecret: String) { + context.dataStore.edit { settings -> + settings[getPreferenceKeyForUser(userId)] = clientSecret + } + } + + override suspend fun getSecret(userId: SessionId): String? { + return context.dataStore.data.first()[getPreferenceKeyForUser(userId)] + } + + override suspend fun resetSecret(userId: SessionId) { + context.dataStore.edit { settings -> + settings.remove(getPreferenceKeyForUser(userId)) + } + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + val keyValues = context.dataStore.data.first().asMap() + val matchingKey = keyValues.keys.find { + keyValues[it] == clientSecret + } + return matchingKey?.name?.let(::SessionId) + } + + private fun getPreferenceKeyForUser(userId: SessionId) = stringPreferencesKey(userId.value) +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt new file mode 100644 index 0000000000..b1cb93e49c --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory + +private const val A_SECRET_PREFIX = "A_SECRET_" + +class FakePushClientSecretFactory : PushClientSecretFactory { + private var index = 0 + + override fun create() = getSecretForUser(index++) + + fun getSecretForUser(i: Int): String { + return A_SECRET_PREFIX + i + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt new file mode 100644 index 0000000000..8c9b577967 --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore + +class InMemoryPushClientSecretStore : PushClientSecretStore { + private val secrets = mutableMapOf<SessionId, String>() + + fun getSecrets(): Map<SessionId, String> = secrets + + override suspend fun storeSecret(userId: SessionId, clientSecret: String) { + secrets[userId] = clientSecret + } + + override suspend fun getSecret(userId: SessionId): String? { + return secrets[userId] + } + + override suspend fun resetSecret(userId: SessionId) { + secrets.remove(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return secrets.keys.firstOrNull { secrets[it] == clientSecret } + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt new file mode 100644 index 0000000000..48e4daa40c --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.pushstore.impl.clientsecret + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val A_USER_ID_0 = SessionId("@A_USER_ID_0:domain") +private val A_USER_ID_1 = SessionId("@A_USER_ID_1:domain") + +private const val A_UNKNOWN_SECRET = "A_UNKNOWN_SECRET" + +internal class PushClientSecretImplTest { + + @Test + fun test() = runTest { + val factory = FakePushClientSecretFactory() + val store = InMemoryPushClientSecretStore() + val sut = PushClientSecretImpl(factory, store) + + val secret0 = factory.getSecretForUser(0) + val secret1 = factory.getSecretForUser(1) + val secret2 = factory.getSecretForUser(2) + + assertThat(store.getSecrets()).isEmpty() + assertThat(sut.getUserIdFromSecret(secret0)).isNull() + // Create a secret + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0) + assertThat(store.getSecrets()).hasSize(1) + // Same secret returned + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret0) + assertThat(store.getSecrets()).hasSize(1) + // Another secret returned for another user + assertThat(sut.getSecretForUser(A_USER_ID_1)).isEqualTo(secret1) + assertThat(store.getSecrets()).hasSize(2) + + // Get users from secrets + assertThat(sut.getUserIdFromSecret(secret0)).isEqualTo(A_USER_ID_0) + assertThat(sut.getUserIdFromSecret(secret1)).isEqualTo(A_USER_ID_1) + // Unknown secret + assertThat(sut.getUserIdFromSecret(A_UNKNOWN_SECRET)).isNull() + + // User signs out + sut.resetSecretForUser(A_USER_ID_0) + assertThat(store.getSecrets()).hasSize(1) + // Create a new secret after reset + assertThat(sut.getSecretForUser(A_USER_ID_0)).isEqualTo(secret2) + + // Check the store content + assertThat(store.getSecrets()).isEqualTo( + mapOf( + A_USER_ID_0 to secret2, + A_USER_ID_1 to secret1, + ) + ) + } +} diff --git a/libraries/rustsdk/.gitignore b/libraries/rustsdk/.gitignore new file mode 100644 index 0000000000..67f29a6964 --- /dev/null +++ b/libraries/rustsdk/.gitignore @@ -0,0 +1,2 @@ +# Built application files +*.aar diff --git a/libraries/rustsdk/build.gradle.kts b/libraries/rustsdk/build.gradle.kts new file mode 100644 index 0000000000..56fd094897 --- /dev/null +++ b/libraries/rustsdk/build.gradle.kts @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file("matrix-rust-sdk.aar")) diff --git a/libraries/session-storage/api/build.gradle.kts b/libraries/session-storage/api/build.gradle.kts new file mode 100644 index 0000000000..1a7107fdc0 --- /dev/null +++ b/libraries/session-storage/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.sessionstorage.api" +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt new file mode 100644 index 0000000000..cc106f960a --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.api + +import java.util.Date + +data class SessionData( + val userId: String, + val deviceId: String, + val accessToken: String, + val refreshToken: String?, + val homeserverUrl: String, + val slidingSyncProxy: String?, + val loginTimestamp: Date?, +) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt new file mode 100644 index 0000000000..d79d700030 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.api + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface SessionStore { + fun isLoggedIn(): Flow<Boolean> + fun sessionsFlow(): Flow<List<SessionData>> + suspend fun storeData(sessionData: SessionData) + suspend fun getSession(sessionId: String): SessionData? + suspend fun getAllSessions(): List<SessionData> + suspend fun getLatestSession(): SessionData? + suspend fun removeSession(sessionId: String) +} + +fun List<SessionData>.toUserList(): List<String> { + return map { it.userId } +} + +fun Flow<List<SessionData>>.toUserListFlow(): Flow<List<String>> { + return map { it.toUserList() } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt new file mode 100644 index 0000000000..7bcb4db792 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.api.observer + +interface SessionListener { + suspend fun onSessionCreated(userId: String) + suspend fun onSessionDeleted(userId: String) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt new file mode 100644 index 0000000000..e61b4e2bba --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.api.observer + +interface SessionObserver { + fun addListener(listener: SessionListener) + fun removeListener(listener: SessionListener) +} diff --git a/libraries/session-storage/impl-memory/build.gradle.kts b/libraries/session-storage/impl-memory/build.gradle.kts new file mode 100644 index 0000000000..3a2f1e6324 --- /dev/null +++ b/libraries/session-storage/impl-memory/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.sessionstorage.impl.memory" +} + +dependencies { + implementation(projects.libraries.sessionStorage.api) + implementation(libs.coroutines.core) +} diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt new file mode 100644 index 0000000000..e23e34983c --- /dev/null +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemorySessionStore.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl.memory + +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class InMemorySessionStore : SessionStore { + + private var sessionDataFlow = MutableStateFlow<SessionData?>(null) + + override fun isLoggedIn(): Flow<Boolean> { + return sessionDataFlow.map { it != null } + } + + override fun sessionsFlow(): Flow<List<SessionData>> { + return sessionDataFlow.map { listOfNotNull(it) } + } + + override suspend fun storeData(sessionData: SessionData) { + sessionDataFlow.value = sessionData + } + + override suspend fun getSession(sessionId: String): SessionData? { + return sessionDataFlow.value.takeIf { it?.userId == sessionId } + } + + override suspend fun getAllSessions(): List<SessionData> { + return listOfNotNull(sessionDataFlow.value) + } + + override suspend fun getLatestSession(): SessionData? { + return sessionDataFlow.value + } + + override suspend fun removeSession(sessionId: String) { + if (sessionDataFlow.value?.userId == sessionId) { + sessionDataFlow.value = null + } + } +} diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts new file mode 100644 index 0000000000..698bfcf230 --- /dev/null +++ b/libraries/session-storage/impl/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.sqldelight) +} + +android { + namespace = "io.element.android.libraries.sessionstorage.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.encryptedDb) + api(projects.libraries.sessionStorage.api) + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.androidx.security.crypto) + implementation(projects.libraries.di) + implementation(libs.sqldelight.coroutines) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) + testImplementation(libs.sqldelight.driver.jvm) +} + +sqldelight { + database("SessionDatabase") { + verifyMigrations = true + } +} diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt new file mode 100644 index 0000000000..9394b66e66 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl + +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.sqldelight.runtime.coroutines.asFlow +import com.squareup.sqldelight.runtime.coroutines.mapToList +import com.squareup.sqldelight.runtime.coroutines.mapToOneOrNull +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DatabaseSessionStore @Inject constructor( + private val database: SessionDatabase, +) : SessionStore { + + override fun isLoggedIn(): Flow<Boolean> { + return database.sessionDataQueries.selectFirst() + .asFlow() + .mapToOneOrNull() + .map { it != null } + } + + override suspend fun storeData(sessionData: SessionData) { + database.sessionDataQueries.insertSessionData(sessionData.toDbModel()) + } + + override suspend fun getLatestSession(): SessionData? { + return database.sessionDataQueries.selectFirst() + .executeAsOneOrNull() + ?.toApiModel() + } + + override suspend fun getSession(sessionId: String): SessionData? { + return database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + } + + override suspend fun getAllSessions(): List<SessionData> { + return database.sessionDataQueries.selectAll() + .executeAsList() + .map { it.toApiModel() } + } + + override fun sessionsFlow(): Flow<List<SessionData>> { + Timber.w("Observing session list!") + return database.sessionDataQueries.selectAll() + .asFlow() + .mapToList() + .map { it.map { sessionData -> sessionData.toApiModel() } } + } + + override suspend fun removeSession(sessionId: String) { + database.sessionDataQueries.removeSession(sessionId) + } +} diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt new file mode 100644 index 0000000000..dbb42a8451 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl + +import io.element.android.libraries.sessionstorage.api.SessionData +import java.util.Date +import io.element.android.libraries.matrix.session.SessionData as DbSessionData + +internal fun SessionData.toDbModel(): DbSessionData { + return DbSessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + slidingSyncProxy = slidingSyncProxy, + loginTimestamp = loginTimestamp?.time, + ) +} + +internal fun DbSessionData.toApiModel(): SessionData { + return SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + slidingSyncProxy = slidingSyncProxy, + loginTimestamp = loginTimestamp?.let { Date(it) } + ) +} diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt new file mode 100644 index 0000000000..323aa93bb7 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl.di + +import android.content.Context +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.impl.SessionDatabase +import io.element.encrypteddb.SqlCipherDriverFactory +import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider + +@Module +@ContributesTo(AppScope::class) +object SessionStorageModule { + @Provides + @SingleIn(AppScope::class) + fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { + val name = "session_database" + val secretFile = context.getDatabasePath("${name}.key") + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) + val driver = SqlCipherDriverFactory(passphraseProvider) + .create(SessionDatabase.Schema, "${name}.db", context) + return SessionDatabase(driver) + } +} diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt new file mode 100644 index 0000000000..8fa5d9dd16 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl.observer + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArraySet +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSessionObserver @Inject constructor( + private val sessionStore: SessionStore, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : SessionObserver { + // Keep only the userId + private var currentUsers: Set<String>? = null + + init { + observeDatabase() + } + + private val listeners = CopyOnWriteArraySet<SessionListener>() + override fun addListener(listener: SessionListener) { + listeners.add(listener) + } + + override fun removeListener(listener: SessionListener) { + listeners.remove(listener) + } + + private fun observeDatabase() { + coroutineScope.launch { + withContext(dispatchers.io) { + sessionStore.sessionsFlow() + .toUserListFlow() + .map { it.toSet() } + .onEach { newUserSet -> + val currentUserSet = currentUsers + if (currentUserSet != null) { + // Compute diff + // Removed user + val removedUsers = currentUserSet - newUserSet + removedUsers.forEach { removedUser -> + listeners.onEach { listener -> + listener.onSessionDeleted(removedUser) + } + } + // Added user + val addedUsers = newUserSet - currentUserSet + addedUsers.forEach { addedUser -> + listeners.onEach { listener -> + listener.onSessionCreated(addedUser) + } + } + } + + currentUsers = newUserSet + } + .collect() + } + } + } +} diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq new file mode 100644 index 0000000000..c3123f2ffb --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -0,0 +1,25 @@ +CREATE TABLE SessionData ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + slidingSyncProxy TEXT, + loginTimestamp INTEGER +); + + +selectFirst: +SELECT * FROM SessionData LIMIT 1; + +selectAll: +SELECT * FROM SessionData; + +selectByUserId: +SELECT * FROM SessionData WHERE userId = ?; + +insertSessionData: +INSERT INTO SessionData VALUES ?; + +removeSession: +DELETE FROM SessionData WHERE userId = ?; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm new file mode 100644 index 0000000000..396a8f28dd --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -0,0 +1,8 @@ +CREATE TABLE SessionData ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + slidingSyncProxy TEXT +); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..3ee7762585 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1 @@ +ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt new file mode 100644 index 0000000000..fc24c5a011 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.sessionstorage.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver +import io.element.android.libraries.matrix.session.SessionData +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class DatabaseSessionStoreTests { + + private lateinit var database: SessionDatabase + private lateinit var databaseSessionStore: DatabaseSessionStore + + private val aSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + slidingSyncProxy = null, + loginTimestamp = null, + ) + + @Before + fun setup() { + // Initialise in memory SQLite driver + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SessionDatabase.Schema.create(driver) + + database = SessionDatabase(driver) + databaseSessionStore = DatabaseSessionStore(database) + } + + @Test + fun `storeData persists the SessionData into the DB`() = runTest { + assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull() + + databaseSessionStore.storeData(aSessionData.toApiModel()) + + assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) + } + + @Test + fun `isLoggedIn emits true while there are sessions in the DB`() = runTest { + databaseSessionStore.isLoggedIn().test { + assertThat(awaitItem()).isFalse() + database.sessionDataQueries.insertSessionData(aSessionData) + assertThat(awaitItem()).isTrue() + database.sessionDataQueries.removeSession(aSessionData.userId) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `getLatestSession gets the first session in the DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val latestSession = databaseSessionStore.getLatestSession()?.toDbModel() + + assertThat(latestSession).isEqualTo(aSessionData) + } + + @Test + fun `getSession returns a matching session in DB if exists`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val foundSession = databaseSessionStore.getSession(aSessionData.userId)?.toDbModel() + + assertThat(foundSession).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(2) + } + + @Test + fun `getSession returns null if a no matching session exists in DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData.copy(userId = "otherUserId")) + + val foundSession = databaseSessionStore.getSession(aSessionData.userId) + + assertThat(foundSession).isNull() + } + + @Test + fun `removeSession removes the associated session in DB`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + + databaseSessionStore.removeSession(aSessionData.userId) + + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() + } +} diff --git a/libraries/testtags/build.gradle.kts b/libraries/testtags/build.gradle.kts new file mode 100644 index 0000000000..dabd2d4d2a --- /dev/null +++ b/libraries/testtags/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.testtags" +} diff --git a/libraries/testtags/src/main/AndroidManifest.xml b/libraries/testtags/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..ab34d2c3dd --- /dev/null +++ b/libraries/testtags/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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 /> diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/Compose.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/Compose.kt new file mode 100644 index 0000000000..966c88fa06 --- /dev/null +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/Compose.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.testtags + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId + +/** + * Add a testTag to a Modifier, to be used by external tool, like TrafficLight for instance. + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.testTag(id: TestTag) = this.then( + semantics { + testTag = id.value + testTagsAsResourceId = true + } +) diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt new file mode 100644 index 0000000000..d832a6168d --- /dev/null +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.testtags + +@JvmInline +value class TestTag internal constructor(val value: String) + +object TestTags { + /** + * OnBoarding screen. + */ + val onBoardingSignIn = TestTag("onboarding-sign_in") + + /** + * Login screen. + */ + val loginChangeServer = TestTag("login-change_server") + val loginEmailUsername = TestTag("login-email_username") + val loginPassword = TestTag("login-password") + val loginContinue = TestTag("login-continue") + + /** + * Change server screen. + */ + val changeServerServer = TestTag("change_server-server") + + /** + * Room list / Home screen. + */ + val homeScreenSettings = TestTag("home_screen-settings") + + /** + * Welcome screen. + */ + val welcomeScreenTitle = TestTag("welcome_screen-title") +} + + diff --git a/libraries/textcomposer/build.gradle.kts b/libraries/textcomposer/build.gradle.kts new file mode 100644 index 0000000000..76d7d00a03 --- /dev/null +++ b/libraries/textcomposer/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.textcomposer" +} + +dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + ksp(libs.showkase.processor) +} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt new file mode 100644 index 0000000000..aa3e745ea2 --- /dev/null +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerMode.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.textcomposer + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import kotlinx.parcelize.Parcelize + +sealed interface MessageComposerMode : Parcelable { + @Parcelize + data class Normal(val content: CharSequence?) : MessageComposerMode + + sealed class Special(open val eventId: EventId?, open val defaultContent: String) : + MessageComposerMode + + @Parcelize + data class Edit(override val eventId: EventId?, override val defaultContent: String, val transactionId: TransactionId?) : + Special(eventId, defaultContent) + + @Parcelize + class Quote(override val eventId: EventId, override val defaultContent: String) : + Special(eventId, defaultContent) + + @Parcelize + class Reply( + val senderName: String, + val attachmentThumbnailInfo: AttachmentThumbnailInfo?, + override val eventId: EventId, + override val defaultContent: String + ) : Special(eventId, defaultContent) + + val relatedEventId: EventId? + get() = when (this) { + is Normal -> null + is Edit -> eventId + is Quote -> eventId + is Reply -> eventId + } + + val isEditing: Boolean + get() = this is Edit + + val isReply: Boolean + get() = this is Reply + + val inThread: Boolean + get() = false // TODO +} diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt new file mode 100644 index 0000000000..8c4c361707 --- /dev/null +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -0,0 +1,552 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.textcomposer + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.VectorIcons +import io.element.android.libraries.designsystem.modifiers.applyIf +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.android.awaitFrame + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun TextComposer( + composerText: String?, + composerMode: MessageComposerMode, + composerCanSendMessage: Boolean, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = FocusRequester(), + onSendMessage: (String) -> Unit = {}, + onResetComposerMode: () -> Unit = {}, + onComposerTextChange: (String) -> Unit = {}, + onAddAttachment: () -> Unit = {}, + onFocusChanged: (Boolean) -> Unit = {}, +) { + val text = composerText.orEmpty() + Row( + modifier.padding( + horizontal = 12.dp, + vertical = 8.dp + ), verticalAlignment = Alignment.Bottom + ) { + AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp)) + Spacer(modifier = Modifier.width(12.dp)) + var lineCount by remember { mutableStateOf(0) } + val roundedCornerSize = remember(lineCount, composerMode) { + if (lineCount > 1 || composerMode is MessageComposerMode.Special) { + 20.dp + } else { + 28.dp + } + } + val roundedCornerSizeState = animateDpAsState( + targetValue = roundedCornerSize, + animationSpec = tween( + durationMillis = 100, + ) + ) + val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value) + val minHeight = 42.dp + val bgColor = ElementTheme.colors.bgSubtleSecondary + // Change border color depending on focus + var hasFocus by remember { mutableStateOf(false) } + val borderColor = if (hasFocus) ElementTheme.colors.borderDisabled else bgColor + Column( + modifier = Modifier + .fillMaxWidth() + .clip(roundedCorners) + .background(color = bgColor) + .border(1.dp, borderColor, roundedCorners) + ) { + if (composerMode is MessageComposerMode.Special) { + ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode) + } + val defaultTypography = ElementTheme.typography.fontBodyLgRegular + Box { + BasicTextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .focusRequester(focusRequester) + .onFocusEvent { + hasFocus = it.hasFocus + onFocusChanged(it.hasFocus) + }, + value = text, + onValueChange = { onComposerTextChange(it) }, + onTextLayout = { + lineCount = it.lineCount + }, + textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary), + cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary), + decorationBox = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = text, + innerTextField = innerTextField, + enabled = true, + singleLine = false, + visualTransformation = VisualTransformation.None, + shape = roundedCorners, + contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 42.dp), + interactionSource = remember { MutableInteractionSource() }, + placeholder = { + Text(stringResource(CommonStrings.common_message), style = defaultTypography) + }, + colors = TextFieldDefaults.colors( + unfocusedTextColor = MaterialTheme.colorScheme.secondary, + focusedTextColor = MaterialTheme.colorScheme.primary, + unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, + focusedPlaceholderColor = ElementTheme.colors.textDisabled, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + unfocusedContainerColor = bgColor, + focusedContainerColor = bgColor, + errorContainerColor = bgColor, + disabledContainerColor = bgColor, + ) + ) + } + ) + + SendButton( + text = text, + canSendMessage = composerCanSendMessage, + onSendMessage = onSendMessage, + composerMode = composerMode, + modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + ) + } + } + } + + // Request focus when changing mode, and show keyboard. + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(composerMode) { + if (composerMode is MessageComposerMode.Special) { + focusRequester.requestFocus() + keyboard?.let { + awaitFrame() + it.show() + } + } + } +} + +@Composable +private fun ComposerModeView( + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + when (composerMode) { + is MessageComposerMode.Edit -> { + EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent.toString(), + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(start = 12.dp) + ) { + Icon( + resourceId = VectorIcons.Edit, + contentDescription = stringResource(CommonStrings.common_editing), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .size(16.dp), + ) + Text( + stringResource(CommonStrings.common_editing), + style = ElementTheme.typography.fontBodySmRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(CommonStrings.action_close), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(4.dp) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Text( + text = senderName, + modifier = Modifier.fillMaxWidth(), + style = ElementTheme.typography.fontBodySmMedium, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.primary, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + maxLines = if (attachmentThumbnailInfo != null) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(CommonStrings.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = MutableInteractionSource(), + indication = rememberRipple(bounded = false) + ), + ) + } +} + +@Composable +private fun AttachmentButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier + .size(30.dp) + .clickable(onClick = onClick), + shape = CircleShape, + color = ElementTheme.colors.iconPrimary + ) { + Image( + modifier = Modifier.size(12.5f.dp), + painter = painterResource(R.drawable.ic_add_attachment), + contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint( + LocalContentColor.current + ) + ) + } +} + +@Composable +private fun BoxScope.SendButton( + text: String, + canSendMessage: Boolean, + onSendMessage: (String) -> Unit, + composerMode: MessageComposerMode, + modifier: Modifier = Modifier, +) { + val interactionSource = MutableInteractionSource() + Box( + modifier = modifier + .clip(CircleShape) + .background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent) + .size(30.dp) + .align(Alignment.BottomEnd) + .applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = { + padding(start = 1.dp) // Center the arrow in the circle + }) + .clickable( + enabled = canSendMessage, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false), + onClick = { + onSendMessage(text) + }), + contentAlignment = Alignment.Center, + ) { + val iconId = when (composerMode) { + is MessageComposerMode.Edit -> R.drawable.ic_tick + else -> R.drawable.ic_send + } + val contentDescription = when (composerMode) { + is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit) + else -> stringResource(CommonStrings.action_send) + } + Icon( + modifier = Modifier.size(16.dp), + resourceId = iconId, + contentDescription = contentDescription, + // Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary + tint = if (canSendMessage) Color.White else ElementTheme.colors.iconDisabled + ) + } +} + +@DayNightPreviews +@Composable +fun TextComposerSimplePreview() = ElementPreview { + Column { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = false, + composerText = "", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Normal(""), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + ) + } +} + +@DayNightPreviews +@Composable +fun TextComposerEditPreview() = ElementPreview { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) +} + +@DayNightPreviews +@Composable +fun TextComposerReplyPreview() = ElementPreview { + Column { + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = null, + defaultContent = "A message\n" + + "With several lines\n" + + "To preview larger textfields and long lines with overflow" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = MediaSource("https://domain.com/image.jpg"), + textContent = "image.jpg", + type = AttachmentThumbnailType.Image, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "image.jpg" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = MediaSource("https://domain.com/video.mp4"), + textContent = "video.mp4", + type = AttachmentThumbnailType.Video, + blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", + ), + defaultContent = "video.mp4" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "logs.txt", + type = AttachmentThumbnailType.File, + blurHash = null, + ), + defaultContent = "logs.txt" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + TextComposer( + onSendMessage = {}, + onComposerTextChange = {}, + composerMode = MessageComposerMode.Reply( + senderName = "Alice", + eventId = EventId("$1234"), + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = null, + type = AttachmentThumbnailType.Location, + blurHash = null, + ), + defaultContent = "Shared location" + ), + onResetComposerMode = {}, + composerCanSendMessage = true, + composerText = "A message", + ) + } +} diff --git a/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml b/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml new file mode 100644 index 0000000000..ac9d53639b --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_add_attachment.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="20dp" + android:height="20dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,773Q457.91,773 441.46,756.54Q425,740.09 425,718.31L425,535L241.69,535Q219.91,535 203.46,518.54Q187,502.09 187,480Q187,457.91 203.46,441.46Q219.91,425 241.69,425L425,425L425,241.69Q425,219.91 441.46,203.46Q457.91,187 480,187Q502.09,187 518.54,203.46Q535,219.91 535,241.69L535,425L718.31,425Q740.09,425 756.54,441.46Q773,457.91 773,480Q773,502.09 756.54,518.54Q740.09,535 718.31,535L535,535L535,718.31Q535,740.09 518.54,756.54Q502.09,773 480,773Z"/> +</vector> diff --git a/libraries/textcomposer/src/main/res/drawable/ic_send.xml b/libraries/textcomposer/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000000..64e0f120c4 --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + <path + android:pathData="M15.404,8.965L1.563,15.882C0.631,16.348 -0.34,15.348 0.116,14.435C0.116,14.435 1.832,10.971 2.303,10.064C2.775,9.156 3.315,8.999 8.331,8.351C8.517,8.327 8.669,8.187 8.669,8C8.669,7.813 8.517,7.673 8.331,7.649C3.315,7.001 2.775,6.844 2.303,5.936C1.832,5.029 0.116,1.565 0.116,1.565C-0.34,0.653 0.631,-0.348 1.563,0.118L15.404,7.036C16.199,7.433 16.199,8.567 15.404,8.965Z" + android:fillColor="#A6ADB7"/> +</vector> diff --git a/libraries/textcomposer/src/main/res/drawable/ic_tick.xml b/libraries/textcomposer/src/main/res/drawable/ic_tick.xml new file mode 100644 index 0000000000..cf1d71a56f --- /dev/null +++ b/libraries/textcomposer/src/main/res/drawable/ic_tick.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="16dp" + android:height="15dp" + android:viewportWidth="16" + android:viewportHeight="15"> + <path + android:pathData="M6.518,14.779C6.953,14.779 7.297,14.597 7.535,14.233L15.403,1.968C15.579,1.692 15.65,1.461 15.65,1.234C15.65,0.657 15.245,0.26 14.662,0.26C14.249,0.26 14.009,0.399 13.759,0.792L6.484,12.348L2.736,7.529C2.492,7.205 2.236,7.07 1.874,7.07C1.277,7.07 0.857,7.489 0.857,8.066C0.857,8.315 0.95,8.565 1.158,8.819L5.495,14.245C5.784,14.606 6.096,14.779 6.518,14.779Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/libraries/textcomposer/src/main/res/values-cs/translations.xml b/libraries/textcomposer/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..8e0524b69a --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-cs/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Přepnout seznam s odrážkami"</string> + <string name="rich_text_editor_code_block">"Přepnout blok kódu"</string> + <string name="rich_text_editor_composer_placeholder">"Zpráva…"</string> + <string name="rich_text_editor_format_bold">"Použít tučný text"</string> + <string name="rich_text_editor_format_italic">"Použít kurzívu"</string> + <string name="rich_text_editor_format_strikethrough">"Použít přeškrtnutí"</string> + <string name="rich_text_editor_format_underline">"Použít podtržení"</string> + <string name="rich_text_editor_full_screen_toggle">"Přepnout režim celé obrazovky"</string> + <string name="rich_text_editor_indent">"Odsazení"</string> + <string name="rich_text_editor_inline_code">"Použít formát inline kódu"</string> + <string name="rich_text_editor_link">"Nastavit odkaz"</string> + <string name="rich_text_editor_numbered_list">"Přepnout číslovaný seznam"</string> + <string name="rich_text_editor_quote">"Přepnout citaci"</string> + <string name="rich_text_editor_unindent">"Zrušit odsazení"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-de/translations.xml b/libraries/textcomposer/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..a28c784792 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-de/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Aufzählungsliste ein-/ausschalten"</string> + <string name="rich_text_editor_code_block">"Codeblock umschalten"</string> + <string name="rich_text_editor_composer_placeholder">"Nachricht…"</string> + <string name="rich_text_editor_format_bold">"Fettformatierung anwenden"</string> + <string name="rich_text_editor_format_italic">"Kursivformat anwenden"</string> + <string name="rich_text_editor_format_strikethrough">"Durchgestrichenes Format anwenden"</string> + <string name="rich_text_editor_format_underline">"Unterstreichungsformat anwenden"</string> + <string name="rich_text_editor_full_screen_toggle">"Vollbildmodus umschalten"</string> + <string name="rich_text_editor_indent">"Einrücken"</string> + <string name="rich_text_editor_inline_code">"Inline-Codeformat anwenden"</string> + <string name="rich_text_editor_link">"Link setzen"</string> + <string name="rich_text_editor_numbered_list">"Nummerierte Liste ein-/ausschalten"</string> + <string name="rich_text_editor_quote">"Zitat umschalten"</string> + <string name="rich_text_editor_unindent">"Einrücken aufheben"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-es/translations.xml b/libraries/textcomposer/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..606e3bde8e --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-es/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Lista de puntos"</string> + <string name="rich_text_editor_code_block">"Bloque de código"</string> + <string name="rich_text_editor_composer_placeholder">"Mensaje…"</string> + <string name="rich_text_editor_format_bold">"Aplicar formato negrita"</string> + <string name="rich_text_editor_format_italic">"Aplicar formato cursiva"</string> + <string name="rich_text_editor_format_strikethrough">"Aplicar formato tachado"</string> + <string name="rich_text_editor_format_underline">"Aplicar formato de subrayado"</string> + <string name="rich_text_editor_full_screen_toggle">"Pantalla completa"</string> + <string name="rich_text_editor_indent">"Añadir sangría"</string> + <string name="rich_text_editor_inline_code">"Código"</string> + <string name="rich_text_editor_link">"Enlazar"</string> + <string name="rich_text_editor_numbered_list">"Lista numérica"</string> + <string name="rich_text_editor_quote">"Cita"</string> + <string name="rich_text_editor_unindent">"Quitar sangría"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-fr/translations.xml b/libraries/textcomposer/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..4b239c0f93 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-fr/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Afficher une liste à puces"</string> + <string name="rich_text_editor_code_block">"Afficher le bloc de code"</string> + <string name="rich_text_editor_composer_placeholder">"Envoyer un message…"</string> + <string name="rich_text_editor_format_bold">"Appliquer le format gras"</string> + <string name="rich_text_editor_format_italic">"Appliquer le format italique"</string> + <string name="rich_text_editor_format_strikethrough">"Appliquer le format barré"</string> + <string name="rich_text_editor_format_underline">"Appliquer le format souligné"</string> + <string name="rich_text_editor_full_screen_toggle">"Afficher en mode plein écran"</string> + <string name="rich_text_editor_indent">"Décaler vers la droite"</string> + <string name="rich_text_editor_inline_code">"Appliquer le formatage de code en ligne"</string> + <string name="rich_text_editor_link">"Définir un lien"</string> + <string name="rich_text_editor_numbered_list">"Afficher une liste numérotée"</string> + <string name="rich_text_editor_quote">"Afficher une citation"</string> + <string name="rich_text_editor_unindent">"Décaler vers la gauche"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-it/translations.xml b/libraries/textcomposer/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..e3034e8dfe --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-it/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Attiva/disattiva l\'elenco puntato"</string> + <string name="rich_text_editor_code_block">"Attiva/disattiva il blocco di codice"</string> + <string name="rich_text_editor_composer_placeholder">"Messaggio…"</string> + <string name="rich_text_editor_format_bold">"Applica il formato in grassetto"</string> + <string name="rich_text_editor_format_italic">"Applicare il formato corsivo"</string> + <string name="rich_text_editor_format_strikethrough">"Applica il formato barrato"</string> + <string name="rich_text_editor_format_underline">"Applicare il formato di sottolineatura"</string> + <string name="rich_text_editor_full_screen_toggle">"Attiva/disattiva la modalità a schermo intero"</string> + <string name="rich_text_editor_indent">"Rientro a destra"</string> + <string name="rich_text_editor_inline_code">"Applicare il formato del codice in linea"</string> + <string name="rich_text_editor_link">"Imposta collegamento"</string> + <string name="rich_text_editor_numbered_list">"Attiva/disattiva elenco numerato"</string> + <string name="rich_text_editor_quote">"Attiva/disattiva citazione"</string> + <string name="rich_text_editor_unindent">"Rientro a sinistra"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-ro/translations.xml b/libraries/textcomposer/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..a7e1a7135c --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-ro/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Comutați lista cu puncte"</string> + <string name="rich_text_editor_code_block">"Comutați blocul de cod"</string> + <string name="rich_text_editor_composer_placeholder">"Mesaj…"</string> + <string name="rich_text_editor_format_bold">"Aplicați formatul aldin"</string> + <string name="rich_text_editor_format_italic">"Aplicați formatul italic"</string> + <string name="rich_text_editor_format_strikethrough">"Aplicați formatul barat"</string> + <string name="rich_text_editor_format_underline">"Aplică formatul de subliniere"</string> + <string name="rich_text_editor_full_screen_toggle">"Comutați modul ecran complet"</string> + <string name="rich_text_editor_indent">"Indentare"</string> + <string name="rich_text_editor_inline_code">"Aplicați formatul de cod inline"</string> + <string name="rich_text_editor_link">"Setați linkul"</string> + <string name="rich_text_editor_numbered_list">"Comutați lista numerotată"</string> + <string name="rich_text_editor_quote">"Aplicați citatul"</string> + <string name="rich_text_editor_unindent">"Dez-identare"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values-sk/translations.xml b/libraries/textcomposer/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..a5f42a60f8 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-sk/translations.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_bullet_list">"Prepnúť zoznam odrážok"</string> + <string name="rich_text_editor_code_block">"Prepnúť blok kódu"</string> + <string name="rich_text_editor_composer_placeholder">"Správa…"</string> + <string name="rich_text_editor_format_bold">"Použiť tučný formát"</string> + <string name="rich_text_editor_format_italic">"Použiť formát kurzívy"</string> + <string name="rich_text_editor_format_strikethrough">"Použiť formát prečiarknutia"</string> + <string name="rich_text_editor_format_underline">"Použiť formát podčiarknutia"</string> + <string name="rich_text_editor_full_screen_toggle">"Prepnúť režim celej obrazovky"</string> + <string name="rich_text_editor_indent">"Odsadenie"</string> + <string name="rich_text_editor_inline_code">"Použiť formát riadkového kódu"</string> + <string name="rich_text_editor_link">"Nastaviť odkaz"</string> + <string name="rich_text_editor_numbered_list">"Prepnúť číslovaný zoznam"</string> + <string name="rich_text_editor_quote">"Prepnúť citáciu"</string> + <string name="rich_text_editor_unindent">"Zrušiť odsadenie"</string> +</resources> diff --git a/libraries/textcomposer/src/main/res/values/localazy.xml b/libraries/textcomposer/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..0bd53c3bee --- /dev/null +++ b/libraries/textcomposer/src/main/res/values/localazy.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="rich_text_editor_a11y_add_attachment">"Add attachment"</string> + <string name="rich_text_editor_bullet_list">"Toggle bullet list"</string> + <string name="rich_text_editor_code_block">"Toggle code block"</string> + <string name="rich_text_editor_composer_placeholder">"Message…"</string> + <string name="rich_text_editor_format_bold">"Apply bold format"</string> + <string name="rich_text_editor_format_italic">"Apply italic format"</string> + <string name="rich_text_editor_format_strikethrough">"Apply strikethrough format"</string> + <string name="rich_text_editor_format_underline">"Apply underline format"</string> + <string name="rich_text_editor_full_screen_toggle">"Toggle full screen mode"</string> + <string name="rich_text_editor_indent">"Indent"</string> + <string name="rich_text_editor_inline_code">"Apply inline code format"</string> + <string name="rich_text_editor_link">"Set link"</string> + <string name="rich_text_editor_numbered_list">"Toggle numbered list"</string> + <string name="rich_text_editor_quote">"Toggle quote"</string> + <string name="rich_text_editor_unindent">"Unindent"</string> +</resources> diff --git a/libraries/theme/README.md b/libraries/theme/README.md new file mode 100644 index 0000000000..9d7bba33ee --- /dev/null +++ b/libraries/theme/README.md @@ -0,0 +1,17 @@ +# Theme Module + +This module contains the theme tokens for the application, including those auto-generated from [Compound](https://github.com/vector-im/compound-design-tokens) and its mappings. + +## Usage + +The module contains public tokens and color schemes that are later used in `MaterialTheme` and added to `ElementTheme` for use in the application. + +All tokens can be accessed through the `ElementTheme` object, which contains the following properties: + +* `ElementTheme.materialColors`: contains all Material color tokens. In Figma, they're prefixed with `M3/`. It's an alias to `MaterialTheme.colorScheme`. +* `ElementTheme.colors`: contains all Compound semantic color tokens. In Figma, they're prefixed with either `Light/` or `Dark/`. +* `ElementTheme.typography`: contains the Compound `TypographyTokens` values. In Figma, they're prefixed with `Android/font/`. + +## Adding new tokens + +All new tokens **should** come from Compound and added to the `compound.generated` package. To map the literal tokens to the semantic ones, you'll have to update both `compoundColorsLight` and `compoundColorsDark` in `CompoundColors.kt`. diff --git a/libraries/theme/build.gradle.kts b/libraries/theme/build.gradle.kts new file mode 100644 index 0000000000..9488565c80 --- /dev/null +++ b/libraries/theme/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.theme" + + dependencies { + ksp(libs.showkase.processor) + kspTest(libs.showkase.processor) + + implementation(libs.accompanist.systemui) + } +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt new file mode 100644 index 0000000000..f273c2dd64 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.google.accompanist.systemuicontroller.SystemUiController +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import io.element.android.libraries.theme.compound.compoundColorsDark +import io.element.android.libraries.theme.compound.compoundColorsLight +import io.element.android.libraries.theme.compound.compoundTypography +import io.element.android.libraries.theme.compound.generated.SemanticColors +import io.element.android.libraries.theme.compound.generated.TypographyTokens + +/** + * Inspired from https://medium.com/@lucasyujideveloper/54cbcbde1ace + */ +object ElementTheme { + /** + * The current [SemanticColors] provided by [ElementTheme]. + * These come from Compound and are the recommended colors to use for custom components. + * In Figma, these colors usually have the `Light/` or `Dark/` prefix. + */ + val colors: SemanticColors + @Composable + @ReadOnlyComposable + get() = LocalCompoundColors.current + + /** + * The current Material 3 [ColorScheme] provided by [ElementTheme], coming from [MaterialTheme]. + * In Figma, these colors usually have the `M3/` prefix. + */ + val materialColors: ColorScheme + @Composable + @ReadOnlyComposable + get() = MaterialTheme.colorScheme + + /** + * Compound [Typography] tokens. In Figma, these have the `Android/font/` prefix. + */ + val typography: TypographyTokens = TypographyTokens + + /** + * Returns whether the theme version used is the light or the dark one. + */ + val isLightTheme: Boolean + @Composable + @ReadOnlyComposable + get() = LocalCompoundColors.current.isLight +} + +/* Global variables (application level) */ +internal val LocalCompoundColors = staticCompositionLocalOf { compoundColorsLight } + +@Composable +fun ElementTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, /* true to enable MaterialYou */ + compoundColors: SemanticColors = if (darkTheme) compoundColorsDark else compoundColorsLight, + materialLightColors: ColorScheme = materialColorSchemeLight, + materialDarkColors: ColorScheme = materialColorSchemeDark, + typography: Typography = compoundTypography, + content: @Composable () -> Unit, +) { + val systemUiController = rememberSystemUiController() + val currentCompoundColor = remember(darkTheme) { + compoundColors.copy() + }.apply { updateColorsFrom(compoundColors) } + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> materialDarkColors + else -> materialLightColors + } + SideEffect { + systemUiController.applyTheme(colorScheme = colorScheme, darkTheme = darkTheme) + } + CompositionLocalProvider( + LocalCompoundColors provides currentCompoundColor, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) + } +} + +/** + * Can be used to force a composable in dark theme. + * It will automatically change the system ui colors back to normal when leaving the composition. + */ +@Composable +fun ForcedDarkElementTheme( + content: @Composable () -> Unit, +) { + val systemUiController = rememberSystemUiController() + val colorScheme = MaterialTheme.colorScheme + val wasDarkTheme = !ElementTheme.colors.isLight + DisposableEffect(Unit) { + onDispose { + systemUiController.applyTheme(colorScheme, wasDarkTheme) + } + } + ElementTheme(darkTheme = true, content = content) +} + +private fun SystemUiController.applyTheme( + colorScheme: ColorScheme, + darkTheme: Boolean, +) { + val useDarkIcons = !darkTheme + setStatusBarColor( + color = colorScheme.background + ) + setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons + ) +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt new file mode 100644 index 0000000000..b797dab86a --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme + +import androidx.compose.ui.graphics.Color + +// ================================================================================================= +// IMPORTANT! +// We should not be adding any new colors here. This file is only for legacy colors. +// In fact, we should try to remove any references to these colors as we +// iterate through the designs. All new colors should come from Compound's Design Tokens. +// ================================================================================================= + +val LinkColor = Color(0xFF0086E6) diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt new file mode 100644 index 0000000000..d211869f71 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.theme.compound.generated.internal.DarkDesignTokens +import io.element.android.libraries.theme.compound.generated.internal.LightDesignTokens +import io.element.android.libraries.theme.previews.ColorsSchemePreview + +internal val materialColorSchemeLight = lightColorScheme( + primary = LightDesignTokens.colorGray1400, + onPrimary = LightDesignTokens.colorThemeBg, + primaryContainer = LightDesignTokens.colorThemeBg, + onPrimaryContainer = LightDesignTokens.colorGray1400, + inversePrimary = LightDesignTokens.colorThemeBg, + secondary = LightDesignTokens.colorGray900, + onSecondary = LightDesignTokens.colorThemeBg, + secondaryContainer = LightDesignTokens.colorGray400, + onSecondaryContainer = LightDesignTokens.colorGray1400, + tertiary = LightDesignTokens.colorGray900, + onTertiary = LightDesignTokens.colorThemeBg, + tertiaryContainer = LightDesignTokens.colorGray1400, + onTertiaryContainer = LightDesignTokens.colorThemeBg, + background = LightDesignTokens.colorThemeBg, + onBackground = LightDesignTokens.colorGray1400, + surface = LightDesignTokens.colorThemeBg, + onSurface = LightDesignTokens.colorGray1400, + surfaceVariant = LightDesignTokens.colorGray300, + onSurfaceVariant = LightDesignTokens.colorGray900, + surfaceTint = LightDesignTokens.colorGray1000, + inverseSurface = LightDesignTokens.colorGray1300, + inverseOnSurface = LightDesignTokens.colorThemeBg, + error = LightDesignTokens.colorRed900, + onError = LightDesignTokens.colorThemeBg, + errorContainer = LightDesignTokens.colorRed400, + onErrorContainer = LightDesignTokens.colorRed900, + outline = LightDesignTokens.colorGray800, + outlineVariant = LightDesignTokens.colorAlphaGray400, + scrim = LightDesignTokens.colorGray1400, +) + +internal val materialColorSchemeDark = darkColorScheme( + primary = DarkDesignTokens.colorGray1400, + onPrimary = DarkDesignTokens.colorThemeBg, + primaryContainer = DarkDesignTokens.colorThemeBg, + onPrimaryContainer = DarkDesignTokens.colorGray1400, + inversePrimary = DarkDesignTokens.colorThemeBg, + secondary = DarkDesignTokens.colorGray900, + onSecondary = DarkDesignTokens.colorThemeBg, + secondaryContainer = DarkDesignTokens.colorGray400, + onSecondaryContainer = DarkDesignTokens.colorGray1400, + tertiary = DarkDesignTokens.colorGray900, + onTertiary = DarkDesignTokens.colorThemeBg, + tertiaryContainer = DarkDesignTokens.colorGray1400, + onTertiaryContainer = DarkDesignTokens.colorThemeBg, + background = DarkDesignTokens.colorThemeBg, + onBackground = DarkDesignTokens.colorGray1400, + surface = DarkDesignTokens.colorThemeBg, + onSurface = DarkDesignTokens.colorGray1400, + surfaceVariant = DarkDesignTokens.colorGray300, + onSurfaceVariant = DarkDesignTokens.colorGray900, + surfaceTint = DarkDesignTokens.colorGray1000, + inverseSurface = DarkDesignTokens.colorGray1300, + inverseOnSurface = DarkDesignTokens.colorThemeBg, + error = DarkDesignTokens.colorRed900, + onError = DarkDesignTokens.colorThemeBg, + errorContainer = DarkDesignTokens.colorRed400, + onErrorContainer = DarkDesignTokens.colorRed900, + outline = DarkDesignTokens.colorGray800, + outlineVariant = DarkDesignTokens.colorAlphaGray400, + scrim = DarkDesignTokens.colorGray300, +) + +@Preview +@Composable +fun ColorsSchemePreviewLight() = ColorsSchemePreview( + Color.Black, + Color.White, + materialColorSchemeLight, +) + +@Preview +@Composable +fun ColorsSchemePreviewDark() = ColorsSchemePreview( + Color.White, + Color.Black, + materialColorSchemeDark, +) diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundColors.kt new file mode 100644 index 0000000000..fb5d44f880 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundColors.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.compound + +import io.element.android.libraries.theme.compound.generated.internal.DarkDesignTokens +import io.element.android.libraries.theme.compound.generated.internal.LightDesignTokens +import io.element.android.libraries.theme.compound.generated.SemanticColors + +internal val compoundColorsLight = SemanticColors( + textPrimary = LightDesignTokens.colorGray1400, + textSecondary = LightDesignTokens.colorGray900, + textPlaceholder = LightDesignTokens.colorGray800, + textDisabled = LightDesignTokens.colorGray800, + textActionPrimary = LightDesignTokens.colorGray1400, + textActionAccent = LightDesignTokens.colorGreen900, + textLinkExternal = LightDesignTokens.colorBlue900, + textCriticalPrimary = LightDesignTokens.colorRed900, + textSuccessPrimary = LightDesignTokens.colorGreen900, + textInfoPrimary = LightDesignTokens.colorBlue900, + textOnSolidPrimary = LightDesignTokens.colorThemeBg, + bgSubtlePrimary = LightDesignTokens.colorGray400, + bgSubtleSecondary = LightDesignTokens.colorBgSubtleSecondaryLevel0, + bgCanvasDefault = LightDesignTokens.colorBgCanvasDefaultLevel1, + bgCanvasDisabled = LightDesignTokens.colorGray200, + bgActionPrimaryRest = LightDesignTokens.colorGray1400, + bgActionPrimaryHovered = LightDesignTokens.colorGray1200, + bgActionPrimaryPressed = LightDesignTokens.colorGray1100, + bgActionPrimaryDisabled = LightDesignTokens.colorGray700, + bgActionSecondaryRest = LightDesignTokens.colorThemeBg, + bgActionSecondaryHovered = LightDesignTokens.colorAlphaGray200, + bgActionSecondaryPressed = LightDesignTokens.colorAlphaGray300, + bgCriticalPrimary = LightDesignTokens.colorRed900, + bgCriticalHovered = LightDesignTokens.colorRed1000, + bgCriticalSubtle = LightDesignTokens.colorRed200, + bgCriticalSubtleHovered = LightDesignTokens.colorRed300, + bgSuccessSubtle = LightDesignTokens.colorGreen200, + bgInfoSubtle = LightDesignTokens.colorBlue200, + borderDisabled = LightDesignTokens.colorGray500, + borderFocused = LightDesignTokens.colorBlue900, + borderInteractivePrimary = LightDesignTokens.colorGray800, + borderInteractiveSecondary = LightDesignTokens.colorGray600, + borderInteractiveHovered = LightDesignTokens.colorGray1100, + borderCriticalPrimary = LightDesignTokens.colorRed900, + borderCriticalHovered = LightDesignTokens.colorRed1000, + borderCriticalSubtle = LightDesignTokens.colorRed500, + borderSuccessSubtle = LightDesignTokens.colorGreen500, + borderInfoSubtle = LightDesignTokens.colorBlue500, + iconPrimary = LightDesignTokens.colorGray1400, + iconSecondary = LightDesignTokens.colorGray900, + iconTertiary = LightDesignTokens.colorGray800, + iconQuaternary = LightDesignTokens.colorGray700, + iconDisabled = LightDesignTokens.colorGray700, + iconPrimaryAlpha = LightDesignTokens.colorAlphaGray1400, + iconSecondaryAlpha = LightDesignTokens.colorAlphaGray900, + iconTertiaryAlpha = LightDesignTokens.colorAlphaGray800, + iconQuaternaryAlpha = LightDesignTokens.colorAlphaGray700, + iconAccentTertiary = LightDesignTokens.colorGreen800, + iconCriticalPrimary = LightDesignTokens.colorRed900, + iconSuccessPrimary = LightDesignTokens.colorGreen900, + iconInfoPrimary = LightDesignTokens.colorBlue900, + iconOnSolidPrimary = LightDesignTokens.colorThemeBg, + isLight = true, +) + +internal val compoundColorsDark = SemanticColors( + textPrimary = DarkDesignTokens.colorGray1400, + textSecondary = DarkDesignTokens.colorGray900, + textPlaceholder = DarkDesignTokens.colorGray800, + textDisabled = DarkDesignTokens.colorGray800, + textActionPrimary = DarkDesignTokens.colorGray1400, + textActionAccent = DarkDesignTokens.colorGreen900, + textLinkExternal = DarkDesignTokens.colorBlue900, + textCriticalPrimary = DarkDesignTokens.colorRed900, + textSuccessPrimary = DarkDesignTokens.colorGreen900, + textInfoPrimary = DarkDesignTokens.colorBlue900, + textOnSolidPrimary = DarkDesignTokens.colorThemeBg, + bgSubtlePrimary = DarkDesignTokens.colorGray400, + // The value DarkDesignTokens.colorBgSubtleSecondaryLevel0 is defined to colorThemeBg, this is not correct, so override the value here until this is fixed, + bgSubtleSecondary = DarkDesignTokens.colorGray300, // DarkDesignTokens.colorBgSubtleSecondaryLevel0 + bgCanvasDefault = DarkDesignTokens.colorBgCanvasDefaultLevel1, + bgCanvasDisabled = DarkDesignTokens.colorGray200, + bgActionPrimaryRest = DarkDesignTokens.colorGray1400, + bgActionPrimaryHovered = DarkDesignTokens.colorGray1200, + bgActionPrimaryPressed = DarkDesignTokens.colorGray1100, + bgActionPrimaryDisabled = DarkDesignTokens.colorGray700, + bgActionSecondaryRest = DarkDesignTokens.colorThemeBg, + bgActionSecondaryHovered = DarkDesignTokens.colorAlphaGray200, + bgActionSecondaryPressed = DarkDesignTokens.colorAlphaGray300, + bgCriticalPrimary = DarkDesignTokens.colorRed900, + bgCriticalHovered = DarkDesignTokens.colorRed1000, + bgCriticalSubtle = DarkDesignTokens.colorRed200, + bgCriticalSubtleHovered = DarkDesignTokens.colorRed300, + bgSuccessSubtle = DarkDesignTokens.colorGreen200, + bgInfoSubtle = DarkDesignTokens.colorBlue200, + borderDisabled = DarkDesignTokens.colorGray500, + borderFocused = DarkDesignTokens.colorBlue900, + borderInteractivePrimary = DarkDesignTokens.colorGray800, + borderInteractiveSecondary = DarkDesignTokens.colorGray600, + borderInteractiveHovered = DarkDesignTokens.colorGray1100, + borderCriticalPrimary = DarkDesignTokens.colorRed900, + borderCriticalHovered = DarkDesignTokens.colorRed1000, + borderCriticalSubtle = DarkDesignTokens.colorRed500, + borderSuccessSubtle = DarkDesignTokens.colorGreen500, + borderInfoSubtle = DarkDesignTokens.colorBlue500, + iconPrimary = DarkDesignTokens.colorGray1400, + iconSecondary = DarkDesignTokens.colorGray900, + iconTertiary = DarkDesignTokens.colorGray800, + iconQuaternary = DarkDesignTokens.colorGray700, + iconDisabled = DarkDesignTokens.colorGray700, + iconPrimaryAlpha = DarkDesignTokens.colorAlphaGray1400, + iconSecondaryAlpha = DarkDesignTokens.colorAlphaGray900, + iconTertiaryAlpha = DarkDesignTokens.colorAlphaGray800, + iconQuaternaryAlpha = DarkDesignTokens.colorAlphaGray700, + iconAccentTertiary = DarkDesignTokens.colorGreen800, + iconCriticalPrimary = DarkDesignTokens.colorRed900, + iconSuccessPrimary = DarkDesignTokens.colorGreen900, + iconInfoPrimary = DarkDesignTokens.colorBlue900, + iconOnSolidPrimary = DarkDesignTokens.colorThemeBg, + isLight = false, +) diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt new file mode 100644 index 0000000000..fe72a5effe --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.compound + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import io.element.android.libraries.theme.compound.generated.TypographyTokens +import com.airbnb.android.showkase.annotation.ShowkaseTypography + +// 32px (Material) vs 34px, it's the closest one +@ShowkaseTypography(name = "M3 Headline Large", group = "Compound") +internal val compoundHeadingXlRegular = TypographyTokens.fontHeadingXlRegular + +// both are 28px +@ShowkaseTypography(name = "M3 Headline Medium", group = "Compound") +internal val compoundHeadingLgRegular = TypographyTokens.fontHeadingLgRegular + +// These are the default M3 values, but we're setting them manually so an update in M3 doesn't break our designs +@ShowkaseTypography(name = "M3 Headline Small", group = "Compound") +internal val defaultHeadlineSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + lineHeight = 32.sp, + fontSize = 24.sp, + letterSpacing = 0.em, +) + +// 22px (Material) vs 20px, it's the closest one +@ShowkaseTypography(name = "M3 Title Large", group = "Compound") +internal val compoundHeadingMdRegular = TypographyTokens.fontHeadingMdRegular + +// 16px both +@ShowkaseTypography(name = "M3 Title Medium", group = "Compound") +internal val compoundBodyLgMedium = TypographyTokens.fontBodyLgMedium + +// 14px both +@ShowkaseTypography(name = "M3 Title Small", group = "Compound") +internal val compoundBodyMdMedium = TypographyTokens.fontBodyMdMedium + +// 16px both +@ShowkaseTypography(name = "M3 Body Large", group = "Compound") +internal val compoundBodyLgRegular = TypographyTokens.fontBodyLgRegular + +// 14px both +@ShowkaseTypography(name = "M3 Body Medium", group = "Compound") +internal val compoundBodyMdRegular = TypographyTokens.fontBodyMdRegular + +// 12px both +@ShowkaseTypography(name = "M3 Body Small", group = "Compound") +internal val compoundBodySmRegular = TypographyTokens.fontBodySmRegular + +// 14px both, Title Small uses the same token so we have to declare it twice +@ShowkaseTypography(name = "M3 Label Large", group = "Compound") +internal val compoundBodyMdMedium_LabelLarge = TypographyTokens.fontBodyMdMedium + +// 12px both +@ShowkaseTypography(name = "M3 Label Medium", group = "Compound") +internal val compoundBodySmMedium = TypographyTokens.fontBodySmMedium + +// 11px both +@ShowkaseTypography(name = "M3 Label Small", group = "Compound") +internal val compoundBodyXsMedium = TypographyTokens.fontBodyXsMedium + +internal val compoundTypography = Typography( + // displayLarge = , 57px (Material) size. We have no equivalent + // displayMedium = , 45px (Material) size. We have no equivalent + // displaySmall = , 36px (Material) size. We have no equivalent + headlineLarge = compoundHeadingXlRegular, + headlineMedium = compoundHeadingLgRegular, + headlineSmall = defaultHeadlineSmall, + titleLarge = compoundHeadingMdRegular, + titleMedium = compoundBodyLgMedium, + titleSmall = compoundBodyMdMedium, + bodyLarge = compoundBodyLgRegular, + bodyMedium = compoundBodyMdRegular, + bodySmall = compoundBodySmRegular, + labelLarge = compoundBodyMdMedium_LabelLarge, + labelMedium = compoundBodySmMedium, + labelSmall = compoundBodyXsMedium, +) diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/DO_NOT_MODIFY.txt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/DO_NOT_MODIFY.txt new file mode 100644 index 0000000000..a6f7dd3f6a --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/DO_NOT_MODIFY.txt @@ -0,0 +1 @@ +Files inside this package are generated automatically from the Compound project (https://github.com/vector-im/compound-design-tokens) and will be batch-replaced when new tokens are generated. diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/SemanticColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/SemanticColors.kt new file mode 100644 index 0000000000..2b3be49533 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/SemanticColors.kt @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:Suppress("all") +package io.element.android.libraries.theme.compound.generated + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + + + +// Do not edit directly +// Generated on Tue, 27 Jun 2023 11:49:05 GMT + + + + + +/** + * This class holds all the semantic tokens of the Compound theme. + */ +@Stable +class SemanticColors( + bgActionPrimaryDisabled: Color, + bgActionPrimaryHovered: Color, + bgActionPrimaryPressed: Color, + bgActionPrimaryRest: Color, + bgActionSecondaryHovered: Color, + bgActionSecondaryPressed: Color, + bgActionSecondaryRest: Color, + bgCanvasDefault: Color, + bgCanvasDisabled: Color, + bgCriticalHovered: Color, + bgCriticalPrimary: Color, + bgCriticalSubtle: Color, + bgCriticalSubtleHovered: Color, + bgInfoSubtle: Color, + bgSubtlePrimary: Color, + bgSubtleSecondary: Color, + bgSuccessSubtle: Color, + borderCriticalHovered: Color, + borderCriticalPrimary: Color, + borderCriticalSubtle: Color, + borderDisabled: Color, + borderFocused: Color, + borderInfoSubtle: Color, + borderInteractiveHovered: Color, + borderInteractivePrimary: Color, + borderInteractiveSecondary: Color, + borderSuccessSubtle: Color, + iconAccentTertiary: Color, + iconCriticalPrimary: Color, + iconDisabled: Color, + iconInfoPrimary: Color, + iconOnSolidPrimary: Color, + iconPrimary: Color, + iconPrimaryAlpha: Color, + iconQuaternary: Color, + iconQuaternaryAlpha: Color, + iconSecondary: Color, + iconSecondaryAlpha: Color, + iconSuccessPrimary: Color, + iconTertiary: Color, + iconTertiaryAlpha: Color, + textActionAccent: Color, + textActionPrimary: Color, + textCriticalPrimary: Color, + textDisabled: Color, + textInfoPrimary: Color, + textLinkExternal: Color, + textOnSolidPrimary: Color, + textPlaceholder: Color, + textPrimary: Color, + textSecondary: Color, + textSuccessPrimary: Color, + isLight: Boolean, +) { + var isLight by mutableStateOf(isLight) + private set + /** Background colour for primary actions. State: Disabled. */ + var bgActionPrimaryDisabled by mutableStateOf(bgActionPrimaryDisabled) + private set + /** Background colour for primary actions. State: Hover. */ + var bgActionPrimaryHovered by mutableStateOf(bgActionPrimaryHovered) + private set + /** Background colour for primary actions. State: Pressed. */ + var bgActionPrimaryPressed by mutableStateOf(bgActionPrimaryPressed) + private set + /** Background colour for primary actions. State: Rest. */ + var bgActionPrimaryRest by mutableStateOf(bgActionPrimaryRest) + private set + /** Background colour for secondary actions. State: Hover. */ + var bgActionSecondaryHovered by mutableStateOf(bgActionSecondaryHovered) + private set + /** Background colour for secondary actions. State: Pressed. */ + var bgActionSecondaryPressed by mutableStateOf(bgActionSecondaryPressed) + private set + /** Background colour for secondary actions. State: Rest. */ + var bgActionSecondaryRest by mutableStateOf(bgActionSecondaryRest) + private set + /** Default global background for the user interface. +Elevation: Default (Level 0) */ + var bgCanvasDefault by mutableStateOf(bgCanvasDefault) + private set + /** Default background for disabled elements. There's no minimum contrast requirement. */ + var bgCanvasDisabled by mutableStateOf(bgCanvasDisabled) + private set + /** High-contrast background color for critical state. State: Hover. */ + var bgCriticalHovered by mutableStateOf(bgCriticalHovered) + private set + /** High-contrast background color for critical state. State: Rest. */ + var bgCriticalPrimary by mutableStateOf(bgCriticalPrimary) + private set + /** Default subtle critical surfaces. State: Rest. */ + var bgCriticalSubtle by mutableStateOf(bgCriticalSubtle) + private set + /** Default subtle critical surfaces. State: Hover. */ + var bgCriticalSubtleHovered by mutableStateOf(bgCriticalSubtleHovered) + private set + /** Subtle background colour for informational elements. State: Rest. */ + var bgInfoSubtle by mutableStateOf(bgInfoSubtle) + private set + /** Medium contrast surfaces. +Elevation: Default (Level 2). */ + var bgSubtlePrimary by mutableStateOf(bgSubtlePrimary) + private set + /** Low contrast surfaces. +Elevation: Default (Level 1). */ + var bgSubtleSecondary by mutableStateOf(bgSubtleSecondary) + private set + /** Subtle background colour for success state elements. State: Rest. */ + var bgSuccessSubtle by mutableStateOf(bgSuccessSubtle) + private set + /** High-contrast border for critical state. State: Hover. */ + var borderCriticalHovered by mutableStateOf(borderCriticalHovered) + private set + /** High-contrast border for critical state. State: Rest. */ + var borderCriticalPrimary by mutableStateOf(borderCriticalPrimary) + private set + /** Subtle border colour for critical state elements. */ + var borderCriticalSubtle by mutableStateOf(borderCriticalSubtle) + private set + /** Used for borders of disabled elements. There's no minimum contrast requirement. */ + var borderDisabled by mutableStateOf(borderDisabled) + private set + /** Used for the focus state outline. */ + var borderFocused by mutableStateOf(borderFocused) + private set + /** Subtle border colour for informational elements. */ + var borderInfoSubtle by mutableStateOf(borderInfoSubtle) + private set + /** Default contrast for accessible interactive element borders. State: Hover. */ + var borderInteractiveHovered by mutableStateOf(borderInteractiveHovered) + private set + /** Default contrast for accessible interactive element borders. State: Rest. */ + var borderInteractivePrimary by mutableStateOf(borderInteractivePrimary) + private set + /** ⚠️ Lowest contrast for non-accessible interactive element borders, <3:1. Only use for non-essential borders. Do not rely exclusively on them. State: Rest. */ + var borderInteractiveSecondary by mutableStateOf(borderInteractiveSecondary) + private set + /** Subtle border colour for success state elements. */ + var borderSuccessSubtle by mutableStateOf(borderSuccessSubtle) + private set + /** Lowest contrast accessible accent icons. */ + var iconAccentTertiary by mutableStateOf(iconAccentTertiary) + private set + /** High-contrast icon for critical state. State: Rest. */ + var iconCriticalPrimary by mutableStateOf(iconCriticalPrimary) + private set + /** Use for icons in disabled elements. There's no minimum contrast requirement. */ + var iconDisabled by mutableStateOf(iconDisabled) + private set + /** High-contrast icon for informational elements. */ + var iconInfoPrimary by mutableStateOf(iconInfoPrimary) + private set + /** Highest contrast icon color on top of high-contrast solid backgrounds like primary, accent, or destructive actions. */ + var iconOnSolidPrimary by mutableStateOf(iconOnSolidPrimary) + private set + /** Highest contrast icons. */ + var iconPrimary by mutableStateOf(iconPrimary) + private set + /** Translucent version of primary icon. Refer to it for intended use. */ + var iconPrimaryAlpha by mutableStateOf(iconPrimaryAlpha) + private set + /** ⚠️ Lowest contrast non-accessible icons, <3:1. Only use for non-essential icons. Do not rely exclusively on them. */ + var iconQuaternary by mutableStateOf(iconQuaternary) + private set + /** Translucent version of quaternary icon. Refer to it for intended use. */ + var iconQuaternaryAlpha by mutableStateOf(iconQuaternaryAlpha) + private set + /** Lower contrast icons. */ + var iconSecondary by mutableStateOf(iconSecondary) + private set + /** Translucent version of secondary icon. Refer to it for intended use. */ + var iconSecondaryAlpha by mutableStateOf(iconSecondaryAlpha) + private set + /** High-contrast icon for success state elements. */ + var iconSuccessPrimary by mutableStateOf(iconSuccessPrimary) + private set + /** Lowest contrast accessible icons. */ + var iconTertiary by mutableStateOf(iconTertiary) + private set + /** Translucent version of tertiary icon. Refer to it for intended use. */ + var iconTertiaryAlpha by mutableStateOf(iconTertiaryAlpha) + private set + /** Accent text colour for plain actions. */ + var textActionAccent by mutableStateOf(textActionAccent) + private set + /** Default text colour for plain actions. */ + var textActionPrimary by mutableStateOf(textActionPrimary) + private set + /** Text colour for destructive plain actions. */ + var textCriticalPrimary by mutableStateOf(textCriticalPrimary) + private set + /** Use for regular text in disabled elements. There's no minimum contrast requirement. */ + var textDisabled by mutableStateOf(textDisabled) + private set + /** Accent text colour for informational elements. */ + var textInfoPrimary by mutableStateOf(textInfoPrimary) + private set + /** Text colour for external links. */ + var textLinkExternal by mutableStateOf(textLinkExternal) + private set + /** For use as text color on top of high-contrast solid backgrounds like primary, accent, or destructive actions. */ + var textOnSolidPrimary by mutableStateOf(textOnSolidPrimary) + private set + /** Use for placeholder text. Placeholder text should be non-essential. Do not rely exclusively on it. */ + var textPlaceholder by mutableStateOf(textPlaceholder) + private set + /** Highest contrast text. */ + var textPrimary by mutableStateOf(textPrimary) + private set + /** Lowest contrast text. */ + var textSecondary by mutableStateOf(textSecondary) + private set + /** Accent text colour for success state elements. */ + var textSuccessPrimary by mutableStateOf(textSuccessPrimary) + private set + + fun copy( + bgActionPrimaryDisabled: Color = this.bgActionPrimaryDisabled, + bgActionPrimaryHovered: Color = this.bgActionPrimaryHovered, + bgActionPrimaryPressed: Color = this.bgActionPrimaryPressed, + bgActionPrimaryRest: Color = this.bgActionPrimaryRest, + bgActionSecondaryHovered: Color = this.bgActionSecondaryHovered, + bgActionSecondaryPressed: Color = this.bgActionSecondaryPressed, + bgActionSecondaryRest: Color = this.bgActionSecondaryRest, + bgCanvasDefault: Color = this.bgCanvasDefault, + bgCanvasDisabled: Color = this.bgCanvasDisabled, + bgCriticalHovered: Color = this.bgCriticalHovered, + bgCriticalPrimary: Color = this.bgCriticalPrimary, + bgCriticalSubtle: Color = this.bgCriticalSubtle, + bgCriticalSubtleHovered: Color = this.bgCriticalSubtleHovered, + bgInfoSubtle: Color = this.bgInfoSubtle, + bgSubtlePrimary: Color = this.bgSubtlePrimary, + bgSubtleSecondary: Color = this.bgSubtleSecondary, + bgSuccessSubtle: Color = this.bgSuccessSubtle, + borderCriticalHovered: Color = this.borderCriticalHovered, + borderCriticalPrimary: Color = this.borderCriticalPrimary, + borderCriticalSubtle: Color = this.borderCriticalSubtle, + borderDisabled: Color = this.borderDisabled, + borderFocused: Color = this.borderFocused, + borderInfoSubtle: Color = this.borderInfoSubtle, + borderInteractiveHovered: Color = this.borderInteractiveHovered, + borderInteractivePrimary: Color = this.borderInteractivePrimary, + borderInteractiveSecondary: Color = this.borderInteractiveSecondary, + borderSuccessSubtle: Color = this.borderSuccessSubtle, + iconAccentTertiary: Color = this.iconAccentTertiary, + iconCriticalPrimary: Color = this.iconCriticalPrimary, + iconDisabled: Color = this.iconDisabled, + iconInfoPrimary: Color = this.iconInfoPrimary, + iconOnSolidPrimary: Color = this.iconOnSolidPrimary, + iconPrimary: Color = this.iconPrimary, + iconPrimaryAlpha: Color = this.iconPrimaryAlpha, + iconQuaternary: Color = this.iconQuaternary, + iconQuaternaryAlpha: Color = this.iconQuaternaryAlpha, + iconSecondary: Color = this.iconSecondary, + iconSecondaryAlpha: Color = this.iconSecondaryAlpha, + iconSuccessPrimary: Color = this.iconSuccessPrimary, + iconTertiary: Color = this.iconTertiary, + iconTertiaryAlpha: Color = this.iconTertiaryAlpha, + textActionAccent: Color = this.textActionAccent, + textActionPrimary: Color = this.textActionPrimary, + textCriticalPrimary: Color = this.textCriticalPrimary, + textDisabled: Color = this.textDisabled, + textInfoPrimary: Color = this.textInfoPrimary, + textLinkExternal: Color = this.textLinkExternal, + textOnSolidPrimary: Color = this.textOnSolidPrimary, + textPlaceholder: Color = this.textPlaceholder, + textPrimary: Color = this.textPrimary, + textSecondary: Color = this.textSecondary, + textSuccessPrimary: Color = this.textSuccessPrimary, + isLight: Boolean = this.isLight, + ) = SemanticColors( + bgActionPrimaryDisabled = bgActionPrimaryDisabled, + bgActionPrimaryHovered = bgActionPrimaryHovered, + bgActionPrimaryPressed = bgActionPrimaryPressed, + bgActionPrimaryRest = bgActionPrimaryRest, + bgActionSecondaryHovered = bgActionSecondaryHovered, + bgActionSecondaryPressed = bgActionSecondaryPressed, + bgActionSecondaryRest = bgActionSecondaryRest, + bgCanvasDefault = bgCanvasDefault, + bgCanvasDisabled = bgCanvasDisabled, + bgCriticalHovered = bgCriticalHovered, + bgCriticalPrimary = bgCriticalPrimary, + bgCriticalSubtle = bgCriticalSubtle, + bgCriticalSubtleHovered = bgCriticalSubtleHovered, + bgInfoSubtle = bgInfoSubtle, + bgSubtlePrimary = bgSubtlePrimary, + bgSubtleSecondary = bgSubtleSecondary, + bgSuccessSubtle = bgSuccessSubtle, + borderCriticalHovered = borderCriticalHovered, + borderCriticalPrimary = borderCriticalPrimary, + borderCriticalSubtle = borderCriticalSubtle, + borderDisabled = borderDisabled, + borderFocused = borderFocused, + borderInfoSubtle = borderInfoSubtle, + borderInteractiveHovered = borderInteractiveHovered, + borderInteractivePrimary = borderInteractivePrimary, + borderInteractiveSecondary = borderInteractiveSecondary, + borderSuccessSubtle = borderSuccessSubtle, + iconAccentTertiary = iconAccentTertiary, + iconCriticalPrimary = iconCriticalPrimary, + iconDisabled = iconDisabled, + iconInfoPrimary = iconInfoPrimary, + iconOnSolidPrimary = iconOnSolidPrimary, + iconPrimary = iconPrimary, + iconPrimaryAlpha = iconPrimaryAlpha, + iconQuaternary = iconQuaternary, + iconQuaternaryAlpha = iconQuaternaryAlpha, + iconSecondary = iconSecondary, + iconSecondaryAlpha = iconSecondaryAlpha, + iconSuccessPrimary = iconSuccessPrimary, + iconTertiary = iconTertiary, + iconTertiaryAlpha = iconTertiaryAlpha, + textActionAccent = textActionAccent, + textActionPrimary = textActionPrimary, + textCriticalPrimary = textCriticalPrimary, + textDisabled = textDisabled, + textInfoPrimary = textInfoPrimary, + textLinkExternal = textLinkExternal, + textOnSolidPrimary = textOnSolidPrimary, + textPlaceholder = textPlaceholder, + textPrimary = textPrimary, + textSecondary = textSecondary, + textSuccessPrimary = textSuccessPrimary, + isLight = isLight, + ) + + fun updateColorsFrom(other: SemanticColors) { + bgActionPrimaryDisabled = other.bgActionPrimaryDisabled + bgActionPrimaryHovered = other.bgActionPrimaryHovered + bgActionPrimaryPressed = other.bgActionPrimaryPressed + bgActionPrimaryRest = other.bgActionPrimaryRest + bgActionSecondaryHovered = other.bgActionSecondaryHovered + bgActionSecondaryPressed = other.bgActionSecondaryPressed + bgActionSecondaryRest = other.bgActionSecondaryRest + bgCanvasDefault = other.bgCanvasDefault + bgCanvasDisabled = other.bgCanvasDisabled + bgCriticalHovered = other.bgCriticalHovered + bgCriticalPrimary = other.bgCriticalPrimary + bgCriticalSubtle = other.bgCriticalSubtle + bgCriticalSubtleHovered = other.bgCriticalSubtleHovered + bgInfoSubtle = other.bgInfoSubtle + bgSubtlePrimary = other.bgSubtlePrimary + bgSubtleSecondary = other.bgSubtleSecondary + bgSuccessSubtle = other.bgSuccessSubtle + borderCriticalHovered = other.borderCriticalHovered + borderCriticalPrimary = other.borderCriticalPrimary + borderCriticalSubtle = other.borderCriticalSubtle + borderDisabled = other.borderDisabled + borderFocused = other.borderFocused + borderInfoSubtle = other.borderInfoSubtle + borderInteractiveHovered = other.borderInteractiveHovered + borderInteractivePrimary = other.borderInteractivePrimary + borderInteractiveSecondary = other.borderInteractiveSecondary + borderSuccessSubtle = other.borderSuccessSubtle + iconAccentTertiary = other.iconAccentTertiary + iconCriticalPrimary = other.iconCriticalPrimary + iconDisabled = other.iconDisabled + iconInfoPrimary = other.iconInfoPrimary + iconOnSolidPrimary = other.iconOnSolidPrimary + iconPrimary = other.iconPrimary + iconPrimaryAlpha = other.iconPrimaryAlpha + iconQuaternary = other.iconQuaternary + iconQuaternaryAlpha = other.iconQuaternaryAlpha + iconSecondary = other.iconSecondary + iconSecondaryAlpha = other.iconSecondaryAlpha + iconSuccessPrimary = other.iconSuccessPrimary + iconTertiary = other.iconTertiary + iconTertiaryAlpha = other.iconTertiaryAlpha + textActionAccent = other.textActionAccent + textActionPrimary = other.textActionPrimary + textCriticalPrimary = other.textCriticalPrimary + textDisabled = other.textDisabled + textInfoPrimary = other.textInfoPrimary + textLinkExternal = other.textLinkExternal + textOnSolidPrimary = other.textOnSolidPrimary + textPlaceholder = other.textPlaceholder + textPrimary = other.textPrimary + textSecondary = other.textSecondary + textSuccessPrimary = other.textSuccessPrimary + isLight = other.isLight + } +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt new file mode 100644 index 0000000000..00a0b82fd7 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +// Do not edit directly +// Generated on Tue, 27 Jun 2023 13:31:52 GMT + + + +@file:Suppress("all") +package io.element.android.libraries.theme.compound.generated + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +object TypographyTokens { + val fontBodyLgMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 22.sp, + fontSize = 16.sp, + letterSpacing = 0.015629999999999998.em, + ) + val fontBodyLgRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 22.sp, + fontSize = 16.sp, + letterSpacing = 0.015629999999999998.em, + ) + val fontBodyMdMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + fontSize = 14.sp, + letterSpacing = 0.01786.em, + ) + val fontBodyMdRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 20.sp, + fontSize = 14.sp, + letterSpacing = 0.01786.em, + ) + val fontBodySmMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 17.sp, + fontSize = 12.sp, + letterSpacing = 0.03333.em, + ) + val fontBodySmRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 17.sp, + fontSize = 12.sp, + letterSpacing = 0.03333.em, + ) + val fontBodyXsMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 15.sp, + fontSize = 11.sp, + letterSpacing = 0.04545.em, + ) + val fontBodyXsRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 15.sp, + fontSize = 11.sp, + letterSpacing = 0.04545.em, + ) + val fontHeadingLgBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 34.sp, + fontSize = 28.sp, + letterSpacing = 0.em, + ) + val fontHeadingLgRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 34.sp, + fontSize = 28.sp, + letterSpacing = 0.em, + ) + val fontHeadingMdBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 27.sp, + fontSize = 22.sp, + letterSpacing = 0.em, + ) + val fontHeadingMdRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 27.sp, + fontSize = 22.sp, + letterSpacing = 0.em, + ) + val fontHeadingSmMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 25.sp, + fontSize = 20.sp, + letterSpacing = 0.em, + ) + val fontHeadingSmRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 25.sp, + fontSize = 20.sp, + letterSpacing = 0.em, + ) + val fontHeadingXlBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 41.sp, + fontSize = 34.sp, + letterSpacing = 0.em, + ) + val fontHeadingXlRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 41.sp, + fontSize = 34.sp, + letterSpacing = 0.em, + ) +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/DarkDesignTokens.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/DarkDesignTokens.kt new file mode 100644 index 0000000000..1930447899 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/DarkDesignTokens.kt @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + + +@file:Suppress("all") +package io.element.android.libraries.theme.compound.generated.internal + +import androidx.compose.ui.graphics.Color + +internal object DarkDesignTokens { + val colorAlphaPink1400 = Color(0xffffebef) + val colorAlphaPink1300 = Color(0xffffd1db) + val colorAlphaPink1200 = Color(0xffffadc0) + val colorAlphaPink1100 = Color(0xfffe86a4) + val colorAlphaPink1000 = Color(0xfaff6691) + val colorAlphaPink900 = Color(0xf5fe4382) + val colorAlphaPink800 = Color(0xccfe1b79) + val colorAlphaPink700 = Color(0x94fd1277) + val colorAlphaPink600 = Color(0x75fb0473) + val colorAlphaPink500 = Color(0xff6b0036) + val colorAlphaPink400 = Color(0xff570024) + val colorAlphaPink300 = Color(0xff470019) + val colorAlphaPink200 = Color(0xff3d0012) + val colorAlphaPink100 = Color(0xff38000f) + val colorAlphaFuchsia1400 = Color(0xfafdecfe) + val colorAlphaFuchsia1300 = Color(0xf2fde0ff) + val colorAlphaFuchsia1200 = Color(0xe8fac3fe) + val colorAlphaFuchsia1100 = Color(0xdbfaa4fe) + val colorAlphaFuchsia1000 = Color(0xd4f790fe) + val colorAlphaFuchsia900 = Color(0xccf172fd) + val colorAlphaFuchsia800 = Color(0xb5eb44fd) + val colorAlphaFuchsia700 = Color(0x8ad82ffe) + val colorAlphaFuchsia600 = Color(0x70d21fff) + val colorAlphaFuchsia500 = Color(0x61ca0aff) + val colorAlphaFuchsia400 = Color(0xff45005c) + val colorAlphaFuchsia300 = Color(0xff36004d) + val colorAlphaFuchsia200 = Color(0xff2d0042) + val colorAlphaFuchsia100 = Color(0xff28003d) + val colorAlphaPurple1400 = Color(0xffeeebff) + val colorAlphaPurple1300 = Color(0xffdfdbff) + val colorAlphaPurple1200 = Color(0xffc7bdff) + val colorAlphaPurple1100 = Color(0xffab9afe) + val colorAlphaPurple1000 = Color(0xfca28bfe) + val colorAlphaPurple900 = Color(0xfa9271fe) + val colorAlphaPurple800 = Color(0xeb7f4dff) + val colorAlphaPurple700 = Color(0xc2712bfd) + val colorAlphaPurple600 = Color(0xab690dfd) + val colorAlphaPurple500 = Color(0xff3d009e) + val colorAlphaPurple400 = Color(0xff2d0080) + val colorAlphaPurple300 = Color(0xff22006b) + val colorAlphaPurple200 = Color(0xff1d005c) + val colorAlphaPurple100 = Color(0xff1a0057) + val colorAlphaBlue1400 = Color(0xffe6effe) + val colorAlphaBlue1300 = Color(0xfccde1fe) + val colorAlphaBlue1200 = Color(0xf7a3c8ff) + val colorAlphaBlue1100 = Color(0xf57cb2fd) + val colorAlphaBlue1000 = Color(0xf062a0fe) + val colorAlphaBlue900 = Color(0xeb4491fd) + val colorAlphaBlue800 = Color(0xd61077fe) + val colorAlphaBlue700 = Color(0xa30665fe) + val colorAlphaBlue600 = Color(0x87015afe) + val colorAlphaBlue500 = Color(0xa1003cbd) + val colorAlphaBlue400 = Color(0xff001e70) + val colorAlphaBlue300 = Color(0xff001366) + val colorAlphaBlue200 = Color(0xff00095c) + val colorAlphaBlue100 = Color(0xff00055c) + val colorAlphaCyan1400 = Color(0xf5e1fbfe) + val colorAlphaCyan1300 = Color(0xebc9f7fd) + val colorAlphaCyan1200 = Color(0xd98af1ff) + val colorAlphaCyan1100 = Color(0xc926e7fd) + val colorAlphaCyan1000 = Color(0xe000bfe0) + val colorAlphaCyan900 = Color(0xff0091bd) + val colorAlphaCyan800 = Color(0xe0007ebd) + val colorAlphaCyan700 = Color(0xff00538a) + val colorAlphaCyan600 = Color(0xff003f75) + val colorAlphaCyan500 = Color(0xff003366) + val colorAlphaCyan400 = Color(0xff00265c) + val colorAlphaCyan300 = Color(0xff001b4d) + val colorAlphaCyan200 = Color(0xff001447) + val colorAlphaCyan100 = Color(0xff001142) + val colorAlphaGreen1400 = Color(0xf5e2fdf1) + val colorAlphaGreen1300 = Color(0xe8c4fde2) + val colorAlphaGreen1200 = Color(0xd486fdce) + val colorAlphaGreen1100 = Color(0xbd26fdbc) + val colorAlphaGreen1000 = Color(0xa61bfebd) + val colorAlphaGreen900 = Color(0x9412fdbe) + val colorAlphaGreen800 = Color(0xff007a62) + val colorAlphaGreen700 = Color(0xff005c45) + val colorAlphaGreen600 = Color(0xff004732) + val colorAlphaGreen500 = Color(0xff003d29) + val colorAlphaGreen400 = Color(0xff002e1b) + val colorAlphaGreen300 = Color(0xff002412) + val colorAlphaGreen200 = Color(0xff001f0e) + val colorAlphaGreen100 = Color(0xff001f0c) + val colorAlphaLime1400 = Color(0xf7e1fdd8) + val colorAlphaLime1300 = Color(0xebc3ffad) + val colorAlphaLime1200 = Color(0xd68dff5c) + val colorAlphaLime1100 = Color(0xbd71fd35) + val colorAlphaLime1000 = Color(0xa860fc2c) + val colorAlphaLime900 = Color(0x9454fd26) + val colorAlphaLime800 = Color(0x732dfd0d) + val colorAlphaLime700 = Color(0xff005c00) + val colorAlphaLime600 = Color(0xff004d00) + val colorAlphaLime500 = Color(0xff003d00) + val colorAlphaLime400 = Color(0xff002e00) + val colorAlphaLime300 = Color(0xff002900) + val colorAlphaLime200 = Color(0xff001f00) + val colorAlphaLime100 = Color(0xff001a00) + val colorAlphaYellow1400 = Color(0xffffedb3) + val colorAlphaYellow1300 = Color(0xfffeda58) + val colorAlphaYellow1200 = Color(0xf0fdc50d) + val colorAlphaYellow1100 = Color(0xffdba100) + val colorAlphaYellow1000 = Color(0xffcc8b00) + val colorAlphaYellow900 = Color(0xffbd7b00) + val colorAlphaYellow800 = Color(0xff9e5c00) + val colorAlphaYellow700 = Color(0xeb854200) + val colorAlphaYellow600 = Color(0xde753300) + val colorAlphaYellow500 = Color(0xff5c2300) + val colorAlphaYellow400 = Color(0xff4d1400) + val colorAlphaYellow300 = Color(0xff420900) + val colorAlphaYellow200 = Color(0xff380300) + val colorAlphaYellow100 = Color(0xff380000) + val colorAlphaOrange1400 = Color(0xffffeadb) + val colorAlphaOrange1300 = Color(0xffffd4b8) + val colorAlphaOrange1200 = Color(0xfcfdb781) + val colorAlphaOrange1100 = Color(0xf7fd953f) + val colorAlphaOrange1000 = Color(0xebfe8310) + val colorAlphaOrange900 = Color(0xd9fe740b) + val colorAlphaOrange800 = Color(0xb5ff5900) + val colorAlphaOrange700 = Color(0xbdc72800) + val colorAlphaOrange600 = Color(0xff850400) + val colorAlphaOrange500 = Color(0xff700000) + val colorAlphaOrange400 = Color(0xff570000) + val colorAlphaOrange300 = Color(0xff470000) + val colorAlphaOrange200 = Color(0xff3d0000) + val colorAlphaOrange100 = Color(0xff380000) + val colorAlphaRed1400 = Color(0xffffe8e5) + val colorAlphaRed1300 = Color(0xffffd3cc) + val colorAlphaRed1200 = Color(0xffffaea3) + val colorAlphaRed1100 = Color(0xffff857a) + val colorAlphaRed1000 = Color(0xffff645c) + val colorAlphaRed900 = Color(0xfffd3d3a) + val colorAlphaRed800 = Color(0xcffe2530) + val colorAlphaRed700 = Color(0x99fe0b24) + val colorAlphaRed600 = Color(0xff850009) + val colorAlphaRed500 = Color(0xff700000) + val colorAlphaRed400 = Color(0xff5c0000) + val colorAlphaRed300 = Color(0xff470000) + val colorAlphaRed200 = Color(0xff3d0000) + val colorAlphaRed100 = Color(0xff380000) + val colorAlphaGray1400 = Color(0xf2f6f9fe) + val colorAlphaGray1300 = Color(0xe3f2f7fd) + val colorAlphaGray1200 = Color(0xc9edf4fc) + val colorAlphaGray1100 = Color(0xade7f0fe) + val colorAlphaGray1000 = Color(0x9ce1eefe) + val colorAlphaGray900 = Color(0x8ae1effe) + val colorAlphaGray800 = Color(0x69e0edff) + val colorAlphaGray700 = Color(0x45e7f1fd) + val colorAlphaGray600 = Color(0x33eceff8) + val colorAlphaGray500 = Color(0x26f4f7fa) + val colorAlphaGray400 = Color(0x1aede7f4) + val colorAlphaGray300 = Color(0x0fe9dbf0) + val colorAlphaGray200 = Color(0x0ad9c3df) + val colorAlphaGray100 = Color(0x05d8dbdf) + val colorPink1400 = Color(0xffffe8ed) + val colorPink1300 = Color(0xffffd2dc) + val colorPink1200 = Color(0xffffabbe) + val colorPink1100 = Color(0xfffe84a2) + val colorPink1000 = Color(0xfffa658f) + val colorPink900 = Color(0xfff4427d) + val colorPink800 = Color(0xffce1865) + val colorPink700 = Color(0xff99114f) + val colorPink600 = Color(0xff7c0c41) + val colorPink500 = Color(0xff6d0036) + val colorPink400 = Color(0xff550024) + val colorPink300 = Color(0xff450018) + val colorPink200 = Color(0xff3c0012) + val colorPink100 = Color(0xff37000f) + val colorFuchsia1400 = Color(0xfff8e9f9) + val colorFuchsia1300 = Color(0xfff1d4f3) + val colorFuchsia1200 = Color(0xffe5b1e9) + val colorFuchsia1100 = Color(0xffd991de) + val colorFuchsia1000 = Color(0xffcf78d7) + val colorFuchsia900 = Color(0xffc560cf) + val colorFuchsia800 = Color(0xffaa36ba) + val colorFuchsia700 = Color(0xff7d2394) + val colorFuchsia600 = Color(0xff65177d) + val colorFuchsia500 = Color(0xff560f6f) + val colorFuchsia400 = Color(0xff46005e) + val colorFuchsia300 = Color(0xff37004e) + val colorFuchsia200 = Color(0xff2e0044) + val colorFuchsia100 = Color(0xff28003d) + val colorPurple1400 = Color(0xffeeebff) + val colorPurple1300 = Color(0xffdedaff) + val colorPurple1200 = Color(0xffc4baff) + val colorPurple1100 = Color(0xffad9cfe) + val colorPurple1000 = Color(0xff9e87fc) + val colorPurple900 = Color(0xff9171f9) + val colorPurple800 = Color(0xff7849ec) + val colorPurple700 = Color(0xff5a27c6) + val colorPurple600 = Color(0xff4a0db1) + val colorPurple500 = Color(0xff3d009e) + val colorPurple400 = Color(0xff2c0080) + val colorPurple300 = Color(0xff22006a) + val colorPurple200 = Color(0xff1c005a) + val colorPurple100 = Color(0xff1a0055) + val colorBlue1400 = Color(0xffe4eefe) + val colorBlue1300 = Color(0xffcbdffc) + val colorBlue1200 = Color(0xffa1c4f8) + val colorBlue1100 = Color(0xff7aacf4) + val colorBlue1000 = Color(0xff5e99f0) + val colorBlue900 = Color(0xff4187eb) + val colorBlue800 = Color(0xff0e67d9) + val colorBlue700 = Color(0xff0b49ab) + val colorBlue600 = Color(0xff083891) + val colorBlue500 = Color(0xff062d80) + val colorBlue400 = Color(0xff001e6f) + val colorBlue300 = Color(0xff001264) + val colorBlue200 = Color(0xff00095d) + val colorBlue100 = Color(0xff00055a) + val colorCyan1400 = Color(0xffdbf2f5) + val colorCyan1300 = Color(0xffb8e5eb) + val colorCyan1200 = Color(0xff78d0dc) + val colorCyan1100 = Color(0xff21bacd) + val colorCyan1000 = Color(0xff02a7c6) + val colorCyan900 = Color(0xff0093be) + val colorCyan800 = Color(0xff0271aa) + val colorCyan700 = Color(0xff005188) + val colorCyan600 = Color(0xff003f75) + val colorCyan500 = Color(0xff003468) + val colorCyan400 = Color(0xff002559) + val colorCyan300 = Color(0xff001b4e) + val colorCyan200 = Color(0xff001448) + val colorCyan100 = Color(0xff001144) + val colorGreen1400 = Color(0xffd9f4e7) + val colorGreen1300 = Color(0xffb5e8d1) + val colorGreen1200 = Color(0xff72d5ae) + val colorGreen1100 = Color(0xff1fc090) + val colorGreen1000 = Color(0xff17ac84) + val colorGreen900 = Color(0xff129a78) + val colorGreen800 = Color(0xff007a62) + val colorGreen700 = Color(0xff005a43) + val colorGreen600 = Color(0xff004832) + val colorGreen500 = Color(0xff003d29) + val colorGreen400 = Color(0xff002e1b) + val colorGreen300 = Color(0xff002513) + val colorGreen200 = Color(0xff001f0e) + val colorGreen100 = Color(0xff001c0b) + val colorLime1400 = Color(0xffdaf6d0) + val colorLime1300 = Color(0xffb6eca3) + val colorLime1200 = Color(0xff77d94f) + val colorLime1100 = Color(0xff56c02c) + val colorLime1000 = Color(0xff47ad26) + val colorLime900 = Color(0xff389b20) + val colorLime800 = Color(0xff1d7c13) + val colorLime700 = Color(0xff005c00) + val colorLime600 = Color(0xff004a00) + val colorLime500 = Color(0xff003e00) + val colorLime400 = Color(0xff003000) + val colorLime300 = Color(0xff002600) + val colorLime200 = Color(0xff002000) + val colorLime100 = Color(0xff001b00) + val colorYellow1400 = Color(0xffffedb1) + val colorYellow1300 = Color(0xfffedb58) + val colorYellow1200 = Color(0xffefbb0b) + val colorYellow1100 = Color(0xffdb9f00) + val colorYellow1000 = Color(0xffcc8c00) + val colorYellow900 = Color(0xffbc7a00) + val colorYellow800 = Color(0xff9d5b00) + val colorYellow700 = Color(0xff7c3e02) + val colorYellow600 = Color(0xff682e03) + val colorYellow500 = Color(0xff5c2400) + val colorYellow400 = Color(0xff4c1400) + val colorYellow300 = Color(0xff410900) + val colorYellow200 = Color(0xff3a0300) + val colorYellow100 = Color(0xff360000) + val colorOrange1400 = Color(0xffffeadb) + val colorOrange1300 = Color(0xffffd5b9) + val colorOrange1200 = Color(0xfffbb37e) + val colorOrange1100 = Color(0xfff6913d) + val colorOrange1000 = Color(0xffeb7a12) + val colorOrange900 = Color(0xffda670d) + val colorOrange800 = Color(0xffb94607) + val colorOrange700 = Color(0xff972206) + val colorOrange600 = Color(0xff830500) + val colorOrange500 = Color(0xff710000) + val colorOrange400 = Color(0xff580000) + val colorOrange300 = Color(0xff470000) + val colorOrange200 = Color(0xff3c0000) + val colorOrange100 = Color(0xff380000) + val colorRed1400 = Color(0xffffe9e6) + val colorRed1300 = Color(0xffffd4cd) + val colorRed1200 = Color(0xffffaea4) + val colorRed1100 = Color(0xffff877c) + val colorRed1000 = Color(0xffff665d) + val colorRed900 = Color(0xfffd3e3c) + val colorRed800 = Color(0xffd1212a) + val colorRed700 = Color(0xff9f0d1e) + val colorRed600 = Color(0xff830009) + val colorRed500 = Color(0xff710000) + val colorRed400 = Color(0xff590000) + val colorRed300 = Color(0xff470000) + val colorRed200 = Color(0xff3e0000) + val colorRed100 = Color(0xff370000) + val colorGray1400 = Color(0xffebeef2) + val colorGray1300 = Color(0xffd9dee4) + val colorGray1200 = Color(0xffbdc3cc) + val colorGray1100 = Color(0xffa3aab4) + val colorGray1000 = Color(0xff9199a4) + val colorGray900 = Color(0xff808994) + val colorGray800 = Color(0xff656c76) + val colorGray700 = Color(0xff4a4f55) + val colorGray600 = Color(0xff3c3f44) + val colorGray500 = Color(0xff323539) + val colorGray400 = Color(0xff26282d) + val colorGray300 = Color(0xff1d1f24) + val colorGray200 = Color(0xff181a1f) + val colorGray100 = Color(0xff14171b) + val colorThemeBg = Color(0xff101317) + val colorBgSubtleSecondaryLevel0 = colorThemeBg + val colorBgCanvasDefaultLevel1 = colorGray300 +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/LightDesignTokens.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/LightDesignTokens.kt new file mode 100644 index 0000000000..aed4a2e01f --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/internal/LightDesignTokens.kt @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + + +@file:Suppress("all") +package io.element.android.libraries.theme.compound.generated.internal; + +import androidx.compose.ui.graphics.Color + +internal object LightDesignTokens { + val colorAlphaPink1400 = Color(0xff420017) + val colorAlphaPink1300 = Color(0xff61002c) + val colorAlphaPink1200 = Color(0xfa79013d) + val colorAlphaPink1100 = Color(0xf79e004c) + val colorAlphaPink1000 = Color(0xf7b60256) + val colorAlphaPink900 = Color(0xf5cf025e) + val colorAlphaPink800 = Color(0xbff50052) + val colorAlphaPink700 = Color(0x78ff0040) + val colorAlphaPink600 = Color(0x54ff053f) + val colorAlphaPink500 = Color(0x3dff0037) + val colorAlphaPink400 = Color(0x21ff0037) + val colorAlphaPink300 = Color(0x14ff1447) + val colorAlphaPink200 = Color(0x0aff0537) + val colorAlphaPink100 = Color(0x05ff0537) + val colorAlphaFuchsia1400 = Color(0xff34004d) + val colorAlphaFuchsia1300 = Color(0xff4d0066) + val colorAlphaFuchsia1200 = Color(0xed5d0279) + val colorAlphaFuchsia1100 = Color(0xe073038c) + val colorAlphaFuchsia1000 = Color(0xd6820198) + val colorAlphaFuchsia900 = Color(0xcc9900ad) + val colorAlphaFuchsia800 = Color(0xa3ab03ba) + val colorAlphaFuchsia700 = Color(0x6eaa04b9) + val colorAlphaFuchsia600 = Color(0x4fb207bb) + val colorAlphaFuchsia500 = Color(0x3bb407c0) + val colorAlphaFuchsia400 = Color(0x21bd09c3) + val colorAlphaFuchsia300 = Color(0x12b60cc6) + val colorAlphaFuchsia200 = Color(0x0ab505cc) + val colorAlphaFuchsia100 = Color(0x05cc05cc) + val colorAlphaPurple1400 = Color(0xff200066) + val colorAlphaPurple1300 = Color(0xff34008f) + val colorAlphaPurple1200 = Color(0xfc4a02b6) + val colorAlphaPurple1100 = Color(0xdb4303c4) + val colorAlphaPurple1000 = Color(0xc94502d4) + val colorAlphaPurple900 = Color(0xba4902ed) + val colorAlphaPurple800 = Color(0x8f3b01f9) + val colorAlphaPurple700 = Color(0x613305ff) + val colorAlphaPurple600 = Color(0x452b05ff) + val colorAlphaPurple500 = Color(0x332605ff) + val colorAlphaPurple400 = Color(0x1f2f0fff) + val colorAlphaPurple300 = Color(0x12381aff) + val colorAlphaPurple200 = Color(0x0a5338ff) + val colorAlphaPurple100 = Color(0x053838ff) + val colorAlphaBlue1400 = Color(0xff000e66) + val colorAlphaBlue1300 = Color(0xff012579) + val colorAlphaBlue1200 = Color(0xfc013693) + val colorAlphaBlue1100 = Color(0xfa0148b2) + val colorAlphaBlue1000 = Color(0xfc0256c5) + val colorAlphaBlue900 = Color(0xfc0165df) + val colorAlphaBlue800 = Color(0xbf0062eb) + val colorAlphaBlue700 = Color(0x820264ed) + val colorAlphaBlue600 = Color(0x5e0663ef) + val colorAlphaBlue500 = Color(0x47096cf6) + val colorAlphaBlue400 = Color(0x290b6af9) + val colorAlphaBlue300 = Color(0x170a70ff) + val colorAlphaBlue200 = Color(0x0d2474ff) + val colorAlphaBlue100 = Color(0x08389cff) + val colorAlphaCyan1400 = Color(0xff001a52) + val colorAlphaCyan1300 = Color(0xff002c61) + val colorAlphaCyan1200 = Color(0xff003f75) + val colorAlphaCyan1100 = Color(0xff00568f) + val colorAlphaCyan1000 = Color(0xff00649e) + val colorAlphaCyan900 = Color(0xff0074ad) + val colorAlphaCyan800 = Color(0xff0095c2) + val colorAlphaCyan700 = Color(0xeb01b7cb) + val colorAlphaCyan600 = Color(0x8a01aac1) + val colorAlphaCyan500 = Color(0x6605abbd) + val colorAlphaCyan400 = Color(0x3800aabd) + val colorAlphaCyan300 = Color(0x1c00a8c2) + val colorAlphaCyan200 = Color(0x0f16abbb) + val colorAlphaCyan100 = Color(0x0816bbbb) + val colorAlphaGreen1400 = Color(0xff002411) + val colorAlphaGreen1300 = Color(0xff00331f) + val colorAlphaGreen1200 = Color(0xff004732) + val colorAlphaGreen1100 = Color(0xff005c45) + val colorAlphaGreen1000 = Color(0xff006b52) + val colorAlphaGreen900 = Color(0xff007a62) + val colorAlphaGreen800 = Color(0xff009975) + val colorAlphaGreen700 = Color(0xf501c18a) + val colorAlphaGreen600 = Color(0x8f01b76e) + val colorAlphaGreen500 = Color(0x6904b96a) + val colorAlphaGreen400 = Color(0x3b07b661) + val colorAlphaGreen300 = Color(0x1c00b85c) + val colorAlphaGreen200 = Color(0x0f16bb69) + val colorAlphaGreen100 = Color(0x0816bb79) + val colorAlphaLime1400 = Color(0xff002400) + val colorAlphaLime1300 = Color(0xff003800) + val colorAlphaLime1200 = Color(0xff004d00) + val colorAlphaLime1100 = Color(0xff006100) + val colorAlphaLime1000 = Color(0xff007000) + val colorAlphaLime900 = Color(0xf5107902) + val colorAlphaLime800 = Color(0xe8209301) + val colorAlphaLime700 = Color(0xdb39bd00) + val colorAlphaLime600 = Color(0xb540ce03) + val colorAlphaLime500 = Color(0x8237ca02) + val colorAlphaLime400 = Color(0x473ace09) + val colorAlphaLime300 = Color(0x262ecf02) + val colorAlphaLime200 = Color(0x1238d40c) + val colorAlphaLime100 = Color(0x0a4fcd1d) + val colorAlphaYellow1400 = Color(0xff420700) + val colorAlphaYellow1300 = Color(0xff571b00) + val colorAlphaYellow1200 = Color(0xff6b2e00) + val colorAlphaYellow1100 = Color(0xff804000) + val colorAlphaYellow1000 = Color(0xff8f4c00) + val colorAlphaYellow900 = Color(0xff9e5a00) + val colorAlphaYellow800 = Color(0xffbd7b00) + val colorAlphaYellow700 = Color(0xffe0a500) + val colorAlphaYellow600 = Color(0xfff0bc00) + val colorAlphaYellow500 = Color(0xfffacc00) + val colorAlphaYellow400 = Color(0x7dffc905) + val colorAlphaYellow300 = Color(0x40ffc905) + val colorAlphaYellow200 = Color(0x21ffc70f) + val colorAlphaYellow100 = Color(0x0fffcd05) + val colorAlphaOrange1400 = Color(0xff470000) + val colorAlphaOrange1300 = Color(0xff610000) + val colorAlphaOrange1200 = Color(0xff850000) + val colorAlphaOrange1100 = Color(0xff992100) + val colorAlphaOrange1000 = Color(0xffad3400) + val colorAlphaOrange900 = Color(0xffbd4500) + val colorAlphaOrange800 = Color(0xffdb6600) + val colorAlphaOrange700 = Color(0xbff56e00) + val colorAlphaOrange600 = Color(0x85fc6f03) + val colorAlphaOrange500 = Color(0x5eff6a00) + val colorAlphaOrange400 = Color(0x38ff6d05) + val colorAlphaOrange300 = Color(0x1cff6c0a) + val colorAlphaOrange200 = Color(0x12ff7d1a) + val colorAlphaOrange100 = Color(0x0aff8138) + val colorAlphaRed1400 = Color(0xff470000) + val colorAlphaRed1300 = Color(0xff610000) + val colorAlphaRed1200 = Color(0xff850007) + val colorAlphaRed1100 = Color(0xfca2011c) + val colorAlphaRed1000 = Color(0xf2bb0217) + val colorAlphaRed900 = Color(0xe8cf0213) + val colorAlphaRed800 = Color(0xc4ff0505) + val colorAlphaRed700 = Color(0x80ff1a05) + val colorAlphaRed600 = Color(0x5cff2205) + val colorAlphaRed500 = Color(0x45ff2605) + val colorAlphaRed400 = Color(0x26ff2b0a) + val colorAlphaRed300 = Color(0x14ff3814) + val colorAlphaRed200 = Color(0x0aff391f) + val colorAlphaRed100 = Color(0x08ff5938) + val colorAlphaGray1400 = Color(0xe6020408) + val colorAlphaGray1300 = Color(0xd603050c) + val colorAlphaGray1200 = Color(0xc402070d) + val colorAlphaGray1100 = Color(0xb5030b16) + val colorAlphaGray1000 = Color(0xa8030c1b) + val colorAlphaGray900 = Color(0x9c031021) + val colorAlphaGray800 = Color(0x8003152b) + val colorAlphaGray700 = Color(0x59011532) + val colorAlphaGray600 = Color(0x42011d3c) + val colorAlphaGray500 = Color(0x33052448) + val colorAlphaGray400 = Color(0x1f052e61) + val colorAlphaGray300 = Color(0x0f052657) + val colorAlphaGray200 = Color(0x0a366881) + val colorAlphaGray100 = Color(0x0536699b) + val colorPink1400 = Color(0xff430017) + val colorPink1300 = Color(0xff5f002b) + val colorPink1200 = Color(0xff7e0642) + val colorPink1100 = Color(0xff9f0850) + val colorPink1000 = Color(0xffb80a5b) + val colorPink900 = Color(0xffd20c65) + val colorPink800 = Color(0xfff7407d) + val colorPink700 = Color(0xffff88a6) + val colorPink600 = Color(0xffffadc0) + val colorPink500 = Color(0xffffc2cf) + val colorPink400 = Color(0xffffdee5) + val colorPink300 = Color(0xffffecf0) + val colorPink200 = Color(0xfffff5f7) + val colorPink100 = Color(0xfffffafb) + val colorFuchsia1400 = Color(0xff34004c) + val colorFuchsia1300 = Color(0xff4e0068) + val colorFuchsia1200 = Color(0xff671481) + val colorFuchsia1100 = Color(0xff822198) + val colorFuchsia1000 = Color(0xff972aaa) + val colorFuchsia900 = Color(0xffad33bd) + val colorFuchsia800 = Color(0xffc85ed1) + val colorFuchsia700 = Color(0xffdb93e1) + val colorFuchsia600 = Color(0xffe7b2ea) + val colorFuchsia500 = Color(0xffedc6f0) + val colorFuchsia400 = Color(0xfff6dff7) + val colorFuchsia300 = Color(0xfffaeefb) + val colorFuchsia200 = Color(0xfffcf5fd) + val colorFuchsia100 = Color(0xfffefafe) + val colorPurple1400 = Color(0xff200066) + val colorPurple1300 = Color(0xff33008d) + val colorPurple1200 = Color(0xff4c05b5) + val colorPurple1100 = Color(0xff5d26cd) + val colorPurple1000 = Color(0xff6b37de) + val colorPurple900 = Color(0xff7a47f1) + val colorPurple800 = Color(0xff9271fd) + val colorPurple700 = Color(0xffb1a0ff) + val colorPurple600 = Color(0xffc5bbff) + val colorPurple500 = Color(0xffd4cdff) + val colorPurple400 = Color(0xffe6e2ff) + val colorPurple300 = Color(0xfff1efff) + val colorPurple200 = Color(0xfff8f7ff) + val colorPurple100 = Color(0xfffbfbff) + val colorBlue1400 = Color(0xff000e65) + val colorBlue1300 = Color(0xff012478) + val colorBlue1200 = Color(0xff043894) + val colorBlue1100 = Color(0xff064ab1) + val colorBlue1000 = Color(0xff0558c7) + val colorBlue900 = Color(0xff0467dd) + val colorBlue800 = Color(0xff4088ee) + val colorBlue700 = Color(0xff7eaff6) + val colorBlue600 = Color(0xffa3c6fa) + val colorBlue500 = Color(0xffbad5fc) + val colorBlue400 = Color(0xffd8e7fe) + val colorBlue300 = Color(0xffe9f2ff) + val colorBlue200 = Color(0xfff4f8ff) + val colorBlue100 = Color(0xfff9fcff) + val colorCyan1400 = Color(0xff00194f) + val colorCyan1300 = Color(0xff002b61) + val colorCyan1200 = Color(0xff004077) + val colorCyan1100 = Color(0xff00548c) + val colorCyan1000 = Color(0xff00629c) + val colorCyan900 = Color(0xff0072ac) + val colorCyan800 = Color(0xff0094c0) + val colorCyan700 = Color(0xff15becf) + val colorCyan600 = Color(0xff76d1dd) + val colorCyan500 = Color(0xff9bdde5) + val colorCyan400 = Color(0xffc7ecf0) + val colorCyan300 = Color(0xffe3f5f8) + val colorCyan200 = Color(0xfff1fafb) + val colorCyan100 = Color(0xfff8fdfd) + val colorGreen1400 = Color(0xff002311) + val colorGreen1300 = Color(0xff003420) + val colorGreen1200 = Color(0xff004933) + val colorGreen1100 = Color(0xff005c45) + val colorGreen1000 = Color(0xff006b52) + val colorGreen900 = Color(0xff007a61) + val colorGreen800 = Color(0xff009b78) + val colorGreen700 = Color(0xff0bc491) + val colorGreen600 = Color(0xff71d7ae) + val colorGreen500 = Color(0xff98e1c1) + val colorGreen400 = Color(0xffc6eedb) + val colorGreen300 = Color(0xffe3f7ed) + val colorGreen200 = Color(0xfff1fbf6) + val colorGreen100 = Color(0xfff8fdfb) + val colorLime1400 = Color(0xff002400) + val colorLime1300 = Color(0xff003600) + val colorLime1200 = Color(0xff004b00) + val colorLime1100 = Color(0xff005f00) + val colorLime1000 = Color(0xff006e00) + val colorLime900 = Color(0xff197d0c) + val colorLime800 = Color(0xff359d18) + val colorLime700 = Color(0xff54c424) + val colorLime600 = Color(0xff76db4c) + val colorLime500 = Color(0xff99e57e) + val colorLime400 = Color(0xffc8f1ba) + val colorLime300 = Color(0xffe0f8d9) + val colorLime200 = Color(0xfff1fcee) + val colorLime100 = Color(0xfff8fdf6) + val colorYellow1400 = Color(0xff410600) + val colorYellow1300 = Color(0xff541a00) + val colorYellow1200 = Color(0xff692e00) + val colorYellow1100 = Color(0xff803f00) + val colorYellow1000 = Color(0xff8f4d00) + val colorYellow900 = Color(0xff9f5b00) + val colorYellow800 = Color(0xffbe7a00) + val colorYellow700 = Color(0xffdea200) + val colorYellow600 = Color(0xfff1bd00) + val colorYellow500 = Color(0xfffbce00) + val colorYellow400 = Color(0xffffe484) + val colorYellow300 = Color(0xfffff2c1) + val colorYellow200 = Color(0xfffff8e0) + val colorYellow100 = Color(0xfffffcf0) + val colorOrange1400 = Color(0xff450000) + val colorOrange1300 = Color(0xff620000) + val colorOrange1200 = Color(0xff850000) + val colorOrange1100 = Color(0xff9b2200) + val colorOrange1000 = Color(0xffac3300) + val colorOrange900 = Color(0xffbc4500) + val colorOrange800 = Color(0xffdc6700) + val colorOrange700 = Color(0xfff89440) + val colorOrange600 = Color(0xfffdb37c) + val colorOrange500 = Color(0xffffc8a1) + val colorOrange400 = Color(0xffffdfc8) + val colorOrange300 = Color(0xffffefe4) + val colorOrange200 = Color(0xfffff6ef) + val colorOrange100 = Color(0xfffffaf7) + val colorRed1400 = Color(0xff450000) + val colorRed1300 = Color(0xff620000) + val colorRed1200 = Color(0xff850006) + val colorRed1100 = Color(0xffa4041d) + val colorRed1000 = Color(0xffbc0f22) + val colorRed900 = Color(0xffd51928) + val colorRed800 = Color(0xffff3d3d) + val colorRed700 = Color(0xffff8c81) + val colorRed600 = Color(0xffffafa5) + val colorRed500 = Color(0xffffc5bc) + val colorRed400 = Color(0xffffdfda) + val colorRed300 = Color(0xffffefec) + val colorRed200 = Color(0xfffff7f6) + val colorRed100 = Color(0xfffffaf9) + val colorGray1400 = Color(0xff1b1d22) + val colorGray1300 = Color(0xff2b2d32) + val colorGray1200 = Color(0xff3c4045) + val colorGray1100 = Color(0xff4c5158) + val colorGray1000 = Color(0xff595e67) + val colorGray900 = Color(0xff656d77) + val colorGray800 = Color(0xff818a95) + val colorGray700 = Color(0xffa6adb7) + val colorGray600 = Color(0xffbdc4cc) + val colorGray500 = Color(0xffcdd3da) + val colorGray400 = Color(0xffe1e6ec) + val colorGray300 = Color(0xfff0f2f5) + val colorGray200 = Color(0xfff7f9fa) + val colorGray100 = Color(0xfffbfcfd) + val colorThemeBg = Color(0xffffffff) + val colorBgSubtleSecondaryLevel0 = colorGray300 + val colorBgCanvasDefaultLevel1 = colorThemeBg +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorListPreview.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorListPreview.kt new file mode 100644 index 0000000000..ad05cb3caa --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorListPreview.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.previews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableMap + +@Composable +fun ColorListPreview( + backgroundColor: Color, + foregroundColor: Color, + colors: ImmutableMap<String, Color>, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(color = backgroundColor) + .fillMaxWidth() + ) { + colors.keys.forEach { name -> + val color = colors[name]!! + ColorPreview(backgroundColor = backgroundColor, foregroundColor = foregroundColor, name = name, color = color) + } + Spacer(modifier = Modifier.height(2.dp)) + } +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorPreview.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorPreview.kt new file mode 100644 index 0000000000..a139fee173 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorPreview.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.previews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.libraries.theme.utils.toHrf + +@Composable +fun ColorPreview( + backgroundColor: Color, + foregroundColor: Color, + name: String, color: Color, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text(text = name + " " + color.toHrf(), fontSize = 6.sp, color = foregroundColor) + val backgroundBrush = Brush.linearGradient( + listOf( + backgroundColor, + foregroundColor, + ) + ) + Row( + modifier = Modifier.background(backgroundBrush) + ) { + repeat(2) { + Box( + modifier = Modifier + .padding(1.dp) + .background(color = color) + .height(10.dp) + .weight(1f) + ) + } + } + } +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorsSchemePreview.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorsSchemePreview.kt new file mode 100644 index 0000000000..db50eb0a3b --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/previews/ColorsSchemePreview.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.previews + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import kotlinx.collections.immutable.persistentMapOf + +@Composable +internal fun ColorsSchemePreview( + backgroundColor: Color, + foregroundColor: Color, + colorScheme: ColorScheme, + modifier: Modifier = Modifier, +) { + val colors = persistentMapOf( + "primary" to colorScheme.primary, + "onPrimary" to colorScheme.onPrimary, + "primaryContainer" to colorScheme.primaryContainer, + "onPrimaryContainer" to colorScheme.onPrimaryContainer, + "inversePrimary" to colorScheme.inversePrimary, + "secondary" to colorScheme.secondary, + "onSecondary" to colorScheme.onSecondary, + "secondaryContainer" to colorScheme.secondaryContainer, + "onSecondaryContainer" to colorScheme.onSecondaryContainer, + "tertiary" to colorScheme.tertiary, + "onTertiary" to colorScheme.onTertiary, + "tertiaryContainer" to colorScheme.tertiaryContainer, + "onTertiaryContainer" to colorScheme.onTertiaryContainer, + "background" to colorScheme.background, + "onBackground" to colorScheme.onBackground, + "surface" to colorScheme.surface, + "onSurface" to colorScheme.onSurface, + "surfaceVariant" to colorScheme.surfaceVariant, + "onSurfaceVariant" to colorScheme.onSurfaceVariant, + "surfaceTint" to colorScheme.surfaceTint, + "inverseSurface" to colorScheme.inverseSurface, + "inverseOnSurface" to colorScheme.inverseOnSurface, + "error" to colorScheme.error, + "onError" to colorScheme.onError, + "errorContainer" to colorScheme.errorContainer, + "onErrorContainer" to colorScheme.onErrorContainer, + "outline" to colorScheme.outline, + "outlineVariant" to colorScheme.outlineVariant, + "scrim" to colorScheme.scrim, + ) + ColorListPreview( + backgroundColor = backgroundColor, + foregroundColor = foregroundColor, + colors = colors, + modifier = modifier, + ) +} diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/showkase/ThemeShowkaseRootModule.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/showkase/ThemeShowkaseRootModule.kt new file mode 100644 index 0000000000..ba6acad6b3 --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/showkase/ThemeShowkaseRootModule.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.libraries.theme.showkase + +import com.airbnb.android.showkase.annotation.ShowkaseRoot +import com.airbnb.android.showkase.annotation.ShowkaseRootModule + +@ShowkaseRoot +class ThemeShowkaseRootModule : ShowkaseRootModule diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/utils/Colors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/utils/Colors.kt new file mode 100644 index 0000000000..ceb711da0a --- /dev/null +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/utils/Colors.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.theme.utils + +import androidx.compose.ui.graphics.Color + +/** + * Convert color to Human Readable Format. + */ +fun Color.toHrf(): String { + return "0x" + value.toString(16).take(8).uppercase() +} diff --git a/libraries/theme/src/main/res/drawable/ic_chat.xml b/libraries/theme/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000000..1fef824a1d --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M2.98 16.3l-1.45 4.95a0.94 0.94 0 0 0 0.25 1 0.94 0.94 0 0 0 1 0.25l4.95-1.45a10.23 10.23 0 0 0 2.1 0.71c0.71 0.16 1.45 0.24 2.2 0.24a9.74 9.74 0 0 0 3.9-0.79 10.1 10.1 0 0 0 3.17-2.14c0.9-0.9 1.61-1.95 2.14-3.17a9.74 9.74 0 0 0 0.79-3.9 9.74 9.74 0 0 0-0.8-3.9 10.1 10.1 0 0 0-2.13-3.17c-0.9-0.9-1.96-1.62-3.17-2.14A9.74 9.74 0 0 0 12.03 2a9.74 9.74 0 0 0-3.9 0.79 10.1 10.1 0 0 0-3.18 2.13c-0.9 0.9-1.61 1.96-2.14 3.18A9.74 9.74 0 0 0 2.03 12a10.18 10.18 0 0 0 0.95 4.3Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_check.xml b/libraries/theme/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..e92733095b --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M9.55 17.57c-0.13 0-0.26-0.02-0.38-0.06a0.88 0.88 0 0 1-0.32-0.21L4.55 13c-0.18-0.18-0.27-0.42-0.26-0.71 0-0.3 0.1-0.53 0.28-0.71a0.95 0.95 0 0 1 0.7-0.28 0.95 0.95 0 0 1 0.7 0.28l3.58 3.57 8.47-8.47c0.19-0.19 0.42-0.28 0.72-0.28 0.29 0 0.53 0.1 0.71 0.28 0.18 0.18 0.28 0.42 0.28 0.7 0 0.3-0.1 0.54-0.28 0.72l-9.2 9.2c-0.1 0.1-0.2 0.17-0.33 0.21a1.1 1.1 0 0 1-0.37 0.06Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_check_circle.xml b/libraries/theme/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000000..ad3aacbe28 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M10.6 13.8l-2.15-2.15a0.95 0.95 0 0 0-0.7-0.28 0.95 0.95 0 0 0-0.7 0.28 0.95 0.95 0 0 0-0.28 0.7 0.95 0.95 0 0 0 0.28 0.7L9.9 15.9c0.2 0.2 0.43 0.3 0.7 0.3 0.27 0 0.5-0.1 0.7-0.3l5.65-5.65a0.95 0.95 0 0 0 0.28-0.7 0.95 0.95 0 0 0-0.28-0.7 0.95 0.95 0 0 0-0.7-0.28 0.95 0.95 0 0 0-0.7 0.28L10.6 13.8ZM12 22a9.74 9.74 0 0 1-3.9-0.79 10.1 10.1 0 0 1-3.17-2.14c-0.9-0.9-1.62-1.95-2.14-3.17A9.74 9.74 0 0 1 2 12a9.74 9.74 0 0 1 0.79-3.9 10.1 10.1 0 0 1 2.14-3.17c0.9-0.9 1.95-1.62 3.17-2.14A9.74 9.74 0 0 1 12 2a9.74 9.74 0 0 1 3.9 0.79 10.1 10.1 0 0 1 3.17 2.14c0.9 0.9 1.62 1.95 2.14 3.17A9.74 9.74 0 0 1 22 12a9.74 9.74 0 0 1-0.79 3.9 10.1 10.1 0 0 1-2.14 3.17c-0.9 0.9-1.95 1.62-3.17 2.14A9.74 9.74 0 0 1 12 22Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_chevron.xml b/libraries/theme/src/main/res/drawable/ic_chevron.xml new file mode 100644 index 0000000000..4ecd3f16b0 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_chevron.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12 14.95c-0.13 0-0.26-0.02-0.38-0.06a0.88 0.88 0 0 1-0.32-0.22l-4.63-4.62a0.9 0.9 0 0 1-0.26-0.69 0.98 0.98 0 0 1 0.29-0.68 0.95 0.95 0 0 1 0.7-0.28 0.95 0.95 0 0 1 0.7 0.28l3.9 3.9 3.92-3.93a0.9 0.9 0 0 1 0.7-0.26 0.98 0.98 0 0 1 0.68 0.29 0.95 0.95 0 0 1 0.28 0.7 0.95 0.95 0 0 1-0.28 0.7l-4.6 4.6c-0.1 0.1-0.2 0.17-0.32 0.2A1.1 1.1 0 0 1 12 14.96Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_close.xml b/libraries/theme/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000000..f334767b67 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12 13.4l-4.9 4.9a0.95 0.95 0 0 1-0.7 0.28 0.95 0.95 0 0 1-0.7-0.28 0.95 0.95 0 0 1-0.28-0.7 0.95 0.95 0 0 1 0.28-0.7l4.9-4.9-4.9-4.9a0.95 0.95 0 0 1-0.28-0.7 0.95 0.95 0 0 1 0.28-0.7 0.95 0.95 0 0 1 0.7-0.27 0.95 0.95 0 0 1 0.7 0.27l4.9 4.9 4.9-4.9a0.95 0.95 0 0 1 0.7-0.27 0.95 0.95 0 0 1 0.7 0.27 0.95 0.95 0 0 1 0.27 0.7 0.95 0.95 0 0 1-0.27 0.7L13.4 12l4.9 4.9a0.95 0.95 0 0 1 0.28 0.7 0.95 0.95 0 0 1-0.28 0.7 0.95 0.95 0 0 1-0.7 0.27 0.95 0.95 0 0 1-0.7-0.27L12 13.4Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_computer.xml b/libraries/theme/src/main/res/drawable/ic_computer.xml new file mode 100644 index 0000000000..e2748c2d4a --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_computer.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M4 18c-0.55 0-1.02-0.2-1.41-0.59A1.93 1.93 0 0 1 2 16V5c0-0.55 0.2-1.02 0.59-1.41A1.93 1.93 0 0 1 4 3h16c0.55 0 1.02 0.2 1.41 0.59C21.81 3.98 22 4.45 22 5v11c0 0.55-0.2 1.02-0.59 1.41A1.93 1.93 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a0.97 0.97 0 0 1-0.71-0.29A0.97 0.97 0 0 1 1 20c0-0.28 0.1-0.52 0.29-0.71A0.97 0.97 0 0 1 2 19h20c0.28 0 0.52 0.1 0.71 0.29C22.91 19.48 23 19.72 23 20s-0.1 0.52-0.29 0.71A0.97 0.97 0 0 1 22 21H2Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_delete.xml b/libraries/theme/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000000..413a570210 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M7 21c-0.55 0-1.02-0.2-1.41-0.59A1.93 1.93 0 0 1 5 19V6a0.97 0.97 0 0 1-0.71-0.29A0.97 0.97 0 0 1 4 5c0-0.28 0.1-0.52 0.29-0.71A0.97 0.97 0 0 1 5 4h4a0.97 0.97 0 0 1 0.29-0.71A0.97 0.97 0 0 1 10 3h4a0.97 0.97 0 0 1 0.71 0.29A0.97 0.97 0 0 1 15 4h4a0.97 0.97 0 0 1 0.71 0.29C19.91 4.48 20 4.72 20 5s-0.1 0.52-0.29 0.71A0.97 0.97 0 0 1 19 6v13c0 0.55-0.2 1.02-0.59 1.41A1.93 1.93 0 0 1 17 21H7Zm2-5c0 0.28 0.1 0.52 0.29 0.71C9.48 16.91 9.72 17 10 17s0.52-0.1 0.71-0.29A0.97 0.97 0 0 0 11 16V9a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 10 8a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 9 9v7Zm4 0c0 0.28 0.1 0.52 0.29 0.71C13.48 16.91 13.72 17 14 17s0.52-0.1 0.71-0.29A0.97 0.97 0 0 0 15 16V9a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 14 8a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 13 9v7Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_error.xml b/libraries/theme/src/main/res/drawable/ic_error.xml new file mode 100644 index 0000000000..d978824039 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_error.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12 17a0.97 0.97 0 0 0 0.71-0.29A0.97 0.97 0 0 0 13 16a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 12 15a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 11 16c0 0.28 0.1 0.52 0.29 0.71C11.48 16.91 11.72 17 12 17Zm0-4c0.28 0 0.52-0.1 0.71-0.29A0.97 0.97 0 0 0 13 12V8a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 12 7a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 11 8v4c0 0.28 0.1 0.52 0.29 0.71C11.48 12.91 11.72 13 12 13Zm0 9a9.74 9.74 0 0 1-3.9-0.79 10.1 10.1 0 0 1-3.17-2.14c-0.9-0.9-1.62-1.95-2.14-3.17A9.74 9.74 0 0 1 2 12a9.74 9.74 0 0 1 0.79-3.9 10.1 10.1 0 0 1 2.14-3.17c0.9-0.9 1.95-1.62 3.17-2.14A9.74 9.74 0 0 1 12 2a9.74 9.74 0 0 1 3.9 0.79 10.1 10.1 0 0 1 3.17 2.14c0.9 0.9 1.62 1.95 2.14 3.17A9.74 9.74 0 0 1 22 12a9.74 9.74 0 0 1-0.79 3.9 10.1 10.1 0 0 1-2.14 3.17c-0.9 0.9-1.95 1.62-3.17 2.14A9.74 9.74 0 0 1 12 22Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_info.xml b/libraries/theme/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000000..69865e325a --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12 17a0.97 0.97 0 0 0 0.71-0.29A0.97 0.97 0 0 0 13 16v-4a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 12 11a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 11 12v4c0 0.28 0.1 0.52 0.29 0.71C11.48 16.91 11.72 17 12 17Zm0-8c0.28 0 0.52-0.1 0.71-0.29A0.97 0.97 0 0 0 13 8a0.97 0.97 0 0 0-0.29-0.71A0.97 0.97 0 0 0 12 7a0.97 0.97 0 0 0-0.71 0.29A0.97 0.97 0 0 0 11 8c0 0.28 0.1 0.52 0.29 0.71C11.48 8.91 11.72 9 12 9Zm0 13a9.74 9.74 0 0 1-3.9-0.79 10.1 10.1 0 0 1-3.17-2.14c-0.9-0.9-1.62-1.95-2.14-3.17A9.74 9.74 0 0 1 2 12a9.74 9.74 0 0 1 0.79-3.9 10.1 10.1 0 0 1 2.14-3.17c0.9-0.9 1.95-1.62 3.17-2.14A9.74 9.74 0 0 1 12 2a9.74 9.74 0 0 1 3.9 0.79 10.1 10.1 0 0 1 3.17 2.14c0.9 0.9 1.62 1.95 2.14 3.17A9.74 9.74 0 0 1 22 12a9.74 9.74 0 0 1-0.79 3.9 10.1 10.1 0 0 1-2.14 3.17c-0.9 0.9-1.95 1.62-3.17 2.14A9.74 9.74 0 0 1 12 22Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_lock.xml b/libraries/theme/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000000..2ada59e82f --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M6 22c-0.55 0-1.02-0.2-1.41-0.59A1.93 1.93 0 0 1 4 20V10c0-0.55 0.2-1.02 0.59-1.41A1.93 1.93 0 0 1 6 8h1V6c0-1.38 0.49-2.56 1.46-3.54C9.44 1.5 10.62 1 12 1s2.56 0.49 3.54 1.46C16.5 3.44 17 4.62 17 6v2h1c0.55 0 1.02 0.2 1.41 0.59C19.81 8.98 20 9.45 20 10v10c0 0.55-0.2 1.02-0.59 1.41A1.93 1.93 0 0 1 18 22H6ZM9 8h6V6c0-0.83-0.3-1.54-0.88-2.13A2.9 2.9 0 0 0 12 3c-0.83 0-1.54 0.3-2.13 0.88A2.9 2.9 0 0 0 9 6v2Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_mobile.xml b/libraries/theme/src/main/res/drawable/ic_mobile.xml new file mode 100644 index 0000000000..f2c46be357 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_mobile.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M7 23c-0.55 0-1.02-0.2-1.41-0.59A1.93 1.93 0 0 1 5 21V3c0-0.55 0.2-1.02 0.59-1.41A1.93 1.93 0 0 1 7 1h10c0.55 0 1.02 0.2 1.41 0.59C18.81 1.98 19 2.45 19 3v18c0 0.55-0.2 1.02-0.59 1.41A1.93 1.93 0 0 1 17 23H7Zm0-5h10V6H7v12Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_thread.xml b/libraries/theme/src/main/res/drawable/ic_thread.xml new file mode 100644 index 0000000000..d3293fab5a --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_thread.xml @@ -0,0 +1,13 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="18dp" + android:height="18dp" + android:viewportWidth="18" + android:viewportHeight="18"> + <path + android:fillColor="#FF000000" + android:pathData="M5 5.25a0.75 0.75 0 0 0 0 1.5h8a0.75 0.75 0 0 0 0-1.5H5Zm0 3a0.75 0.75 0 0 0 0 1.5h4a0.75 0.75 0 1 0 0-1.5H5Z"/> + <path + android:fillColor="#FF000000" + android:fillType="evenOdd" + android:pathData="M3 0.25A2.75 2.75 0 0 0 0.25 3v14a0.75 0.75 0 0 0 1.2 0.6L4.92 15c0.21-0.16 0.48-0.25 0.75-0.25H15A2.75 2.75 0 0 0 17.75 12V3A2.75 2.75 0 0 0 15 0.25H3ZM1.75 3c0-0.69 0.56-1.25 1.25-1.25h12c0.69 0 1.25 0.56 1.25 1.25v9c0 0.69-0.56 1.25-1.25 1.25H5.67a2.75 2.75 0 0 0-1.65 0.55l-2.27 1.7V3Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_user.xml b/libraries/theme/src/main/res/drawable/ic_user.xml new file mode 100644 index 0000000000..5f61985ce4 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_user.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:strokeColor="#FF000000" + android:strokeWidth="0.03" + android:pathData="M5.84 17.1v0.02l0.02-0.01a10.42 10.42 0 0 1 2.84-1.54 9.72 9.72 0 0 1 3.3-0.56c1.15 0 2.25 0.19 3.3 0.56a10.42 10.42 0 0 1 2.84 1.54h0.01 0.01a7.74 7.74 0 0 0 1.36-2.33 7.85 7.85 0 0 0 0.5-2.78c0-2.22-0.79-4.11-2.35-5.67-1.56-1.56-3.45-2.34-5.67-2.34-2.22 0-4.11 0.78-5.67 2.34C4.77 7.89 3.99 9.78 3.99 12c0 0.98 0.16 1.91 0.49 2.78 0.32 0.87 0.78 1.64 1.36 2.33ZM12 13c-0.98 0-1.8-0.34-2.48-1.01-0.67-0.67-1-1.5-1-2.48s0.33-1.8 1-2.48c0.67-0.67 1.5-1 2.48-1s1.8 0.33 2.48 1c0.67 0.67 1 1.5 1 2.48s-0.33 1.8-1 2.48c-0.67 0.67-1.5 1-2.48 1Zm0 9a9.72 9.72 0 0 1-3.9-0.79 10.09 10.09 0 0 1-3.17-2.13A10.09 10.09 0 0 1 2.8 15.9 9.72 9.72 0 0 1 2 12c0-1.38 0.27-2.68 0.79-3.9a10.09 10.09 0 0 1 2.13-3.17A10.09 10.09 0 0 1 8.1 2.8 9.72 9.72 0 0 1 12 2c1.38 0 2.68 0.27 3.9 0.79a10.09 10.09 0 0 1 3.17 2.13c0.9 0.9 1.6 1.96 2.13 3.17A9.72 9.72 0 0 1 22 12a9.73 9.73 0 0 1-0.79 3.9 10.09 10.09 0 0 1-2.13 3.17 10.09 10.09 0 0 1-3.17 2.13A9.72 9.72 0 0 1 12 22Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml b/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml new file mode 100644 index 0000000000..3f20783ee4 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M19.3 16.5l-3.2-3.2c0.13-0.28 0.23-0.57 0.3-0.86 0.07-0.3 0.1-0.6 0.1-0.94 0-1.25-0.44-2.31-1.31-3.19C14.3 7.44 13.25 7 12 7a4.2 4.2 0 0 0-0.94 0.1 4.24 4.24 0 0 0-0.86 0.3L7.65 4.85A11.08 11.08 0 0 1 12 4c2.38 0 4.53 0.63 6.42 1.89 1.9 1.26 3.33 2.9 4.28 4.91 0.05 0.08 0.08 0.19 0.1 0.31s0.03 0.26 0.03 0.39a1.97 1.97 0 0 1-0.13 0.7 11.49 11.49 0 0 1-1.44 2.38 10.47 10.47 0 0 1-1.96 1.92Zm-0.2 5.4l-3.5-3.45c-0.58 0.18-1.17 0.32-1.76 0.41-0.6 0.1-1.2 0.14-1.84 0.14-2.38 0-4.53-0.63-6.42-1.89-1.9-1.26-3.33-2.9-4.28-4.91a0.81 0.81 0 0 1-0.1-0.31 2.93 2.93 0 0 1 0-0.77 0.8 0.8 0 0 1 0.1-0.3c0.35-0.75 0.77-1.44 1.25-2.07A13.3 13.3 0 0 1 4.15 7L2.07 4.9A0.93 0.93 0 0 1 1.8 4.21c0-0.27 0.1-0.51 0.3-0.71a0.95 0.95 0 0 1 0.7-0.28 0.95 0.95 0 0 1 0.7 0.28l17 17c0.18 0.18 0.28 0.41 0.29 0.69a0.93 0.93 0 0 1-0.29 0.71 0.95 0.95 0 0 1-0.7 0.28 0.95 0.95 0 0 1-0.7-0.28ZM12 16a4.9 4.9 0 0 0 0.51-0.03c0.16-0.01 0.33-0.05 0.52-0.1l-5.4-5.4c-0.05 0.19-0.09 0.36-0.1 0.52A4.81 4.81 0 0 0 7.5 11.5c0 1.25 0.44 2.31 1.31 3.19C9.7 15.56 10.75 16 12 16Zm2.65-4.15l-3-3c0.95-0.15 1.73 0.12 2.33 0.8 0.6 0.68 0.82 1.42 0.67 2.2Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml b/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml new file mode 100644 index 0000000000..1283a1512b --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M12 16c1.25 0 2.31-0.44 3.19-1.31 0.87-0.88 1.31-1.94 1.31-3.19s-0.44-2.31-1.31-3.19C14.3 7.44 13.25 7 12 7S9.69 7.44 8.81 8.31C7.94 9.2 7.5 10.25 7.5 11.5s0.44 2.31 1.31 3.19C9.7 15.56 10.75 16 12 16Zm0-1.8c-0.75 0-1.39-0.26-1.91-0.79A2.6 2.6 0 0 1 9.3 11.5c0-0.75 0.26-1.39 0.79-1.91A2.6 2.6 0 0 1 12 8.8c0.75 0 1.39 0.26 1.91 0.79 0.53 0.52 0.79 1.16 0.79 1.91s-0.26 1.39-0.79 1.91A2.6 2.6 0 0 1 12 14.2Zm0 4.8c-2.32 0-4.43-0.61-6.35-1.84-1.92-1.22-3.37-2.88-4.35-4.96a0.81 0.81 0 0 1-0.1-0.31 2.93 2.93 0 0 1 0-0.78 0.81 0.81 0 0 1 0.1-0.31c0.98-2.08 2.43-3.74 4.35-4.96C7.57 4.6 9.68 4 12 4c2.32 0 4.43 0.61 6.35 1.84 1.92 1.22 3.37 2.88 4.35 4.96 0.05 0.08 0.08 0.19 0.1 0.31a2.92 2.92 0 0 1 0 0.78 0.81 0.81 0 0 1-0.1 0.31c-0.98 2.08-2.43 3.74-4.35 4.96C16.43 18.4 14.32 19 12 19Z"/> +</vector> diff --git a/libraries/theme/src/main/res/drawable/ic_web_browser.xml b/libraries/theme/src/main/res/drawable/ic_web_browser.xml new file mode 100644 index 0000000000..080ce75905 --- /dev/null +++ b/libraries/theme/src/main/res/drawable/ic_web_browser.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FF000000" + android:pathData="M4 20c-0.55 0-1.02-0.2-1.41-0.59A1.93 1.93 0 0 1 2 18V6c0-0.55 0.2-1.02 0.59-1.41A1.93 1.93 0 0 1 4 4h16c0.55 0 1.02 0.2 1.41 0.59C21.81 4.98 22 5.45 22 6v12c0 0.55-0.2 1.02-0.59 1.41A1.93 1.93 0 0 1 20 20H4Zm0-2h16V8H4v10Z"/> +</vector> diff --git a/libraries/ui-strings/README.md b/libraries/ui-strings/README.md new file mode 100644 index 0000000000..9cc2e8507f --- /dev/null +++ b/libraries/ui-strings/README.md @@ -0,0 +1,5 @@ +## Module ui-strings + +This module contains all the strings for the project. + +For more details, see [the dedicated README.md file](../../tools/localazy/README.md) diff --git a/libraries/ui-strings/build.gradle.kts b/libraries/ui-strings/build.gradle.kts new file mode 100644 index 0000000000..f3ec759908 --- /dev/null +++ b/libraries/ui-strings/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.ui.strings" +} diff --git a/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonPlurals.kt b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonPlurals.kt new file mode 100644 index 0000000000..ea5697056e --- /dev/null +++ b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonPlurals.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.ui.strings + +typealias CommonPlurals = R.plurals diff --git a/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonStrings.kt b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonStrings.kt new file mode 100644 index 0000000000..1d6bf3e1d4 --- /dev/null +++ b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonStrings.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.ui.strings + +typealias CommonStrings = R.string diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000000..9d897db9d8 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -0,0 +1,189 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Skrýt heslo"</string> + <string name="a11y_send_files">"Odeslat soubory"</string> + <string name="a11y_show_password">"Zobrazit heslo"</string> + <string name="a11y_user_menu">"Uživatelské menu"</string> + <string name="action_accept">"Přijmout"</string> + <string name="action_back">"Zpět"</string> + <string name="action_cancel">"Zrušit"</string> + <string name="action_choose_photo">"Vybrat fotku"</string> + <string name="action_clear">"Vymazat"</string> + <string name="action_close">"Zavřít"</string> + <string name="action_complete_verification">"Dokončit ověření"</string> + <string name="action_confirm">"Potvrdit"</string> + <string name="action_continue">"Pokračovat"</string> + <string name="action_copy">"Kopírovat"</string> + <string name="action_copy_link">"Kopírovat odkaz"</string> + <string name="action_copy_link_to_message">"Kopírovat odkaz na zprávu"</string> + <string name="action_create">"Vytvořit"</string> + <string name="action_create_a_room">"Vytvořit místnost"</string> + <string name="action_decline">"Odmítnout"</string> + <string name="action_disable">"Zakázat"</string> + <string name="action_done">"Hotovo"</string> + <string name="action_edit">"Upravit"</string> + <string name="action_enable">"Povolit"</string> + <string name="action_forgot_password">"Zapomněli jste heslo?"</string> + <string name="action_forward">"Vpřed"</string> + <string name="action_invite">"Pozvat"</string> + <string name="action_invite_friends">"Pozvat přátele"</string> + <string name="action_invite_friends_to_app">"Pozvat přátele do %1$s"</string> + <string name="action_invite_people_to_app">"Pozvat lidi na %1$s"</string> + <string name="action_invites_list">"Pozvánky"</string> + <string name="action_learn_more">"Zjistit více"</string> + <string name="action_leave">"Odejít"</string> + <string name="action_leave_room">"Opustit místnost"</string> + <string name="action_next">"Další"</string> + <string name="action_no">"Ne"</string> + <string name="action_not_now">"Teď ne"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Otevřít v aplikaci"</string> + <string name="action_quick_reply">"Rychlá odpověď"</string> + <string name="action_quote">"Citovat"</string> + <string name="action_remove">"Odstranit"</string> + <string name="action_reply">"Odpovědět"</string> + <string name="action_report_bug">"Nahlásit chybu"</string> + <string name="action_report_content">"Nahlásit obsah"</string> + <string name="action_retry">"Zkusit znovu"</string> + <string name="action_retry_decryption">"Opakovat dešifrování"</string> + <string name="action_save">"Uložit"</string> + <string name="action_search">"Hledat"</string> + <string name="action_send">"Odeslat"</string> + <string name="action_send_message">"Odeslat zprávu"</string> + <string name="action_share">"Sdílet"</string> + <string name="action_share_link">"Sdílet odkaz"</string> + <string name="action_skip">"Přeskočit"</string> + <string name="action_start">"Začít"</string> + <string name="action_start_chat">"Zahájit chat"</string> + <string name="action_start_verification">"Zahájit ověření"</string> + <string name="action_static_map_load">"Klepnutím načtete mapu"</string> + <string name="action_take_photo">"Vyfotit"</string> + <string name="action_view_source">"Zobrazit zdroj"</string> + <string name="action_yes">"Ano"</string> + <string name="common_about">"O aplikaci"</string> + <string name="common_acceptable_use_policy">"Zásady používání"</string> + <string name="common_analytics">"Analytika"</string> + <string name="common_audio">"Zvuk"</string> + <string name="common_bubbles">"Bubliny"</string> + <string name="common_copyright">"Autorská práva"</string> + <string name="common_creating_room">"Vytváření místnosti…"</string> + <string name="common_current_user_left_room">"Opustit místnost"</string> + <string name="common_decryption_error">"Chyba dešifrování"</string> + <string name="common_developer_options">"Možnosti pro vývojáře"</string> + <string name="common_edited_suffix">"(upraveno)"</string> + <string name="common_editing">"Úpravy"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Šifrování povoleno"</string> + <string name="common_error">"Chyba"</string> + <string name="common_file">"Soubor"</string> + <string name="common_file_saved_on_disk_android">"Soubor byl uložen do složky Stažené soubory"</string> + <string name="common_forward_message">"Přeposlat zprávu"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Obrázek"</string> + <string name="common_invite_unknown_profile">"Tento Matrix identifikátor nelze najít, takže pozvánka nemusí být přijata."</string> + <string name="common_leaving_room">"Opuštění místnosti"</string> + <string name="common_link_copied_to_clipboard">"Odkaz zkopírován do schránky"</string> + <string name="common_loading">"Načítání…"</string> + <string name="common_message">"Zpráva"</string> + <string name="common_message_layout">"Rozložení zprávy"</string> + <string name="common_message_removed">"Zpráva byla odstraněna"</string> + <string name="common_modern">"Moderní"</string> + <string name="common_mute">"Ztlumit"</string> + <string name="common_no_results">"Žádné výsledky"</string> + <string name="common_offline">"Offline"</string> + <string name="common_password">"Heslo"</string> + <string name="common_people">"Lidé"</string> + <string name="common_permalink">"Trvalý odkaz"</string> + <string name="common_privacy_policy">"Zásady ochrany osobních údajů"</string> + <string name="common_reactions">"Reakce"</string> + <string name="common_refreshing">"Obnovování…"</string> + <string name="common_replying_to">"Odpověď na %1$s"</string> + <string name="common_report_a_bug">"Nahlásit chybu"</string> + <string name="common_report_submitted">"Zpráva odeslána"</string> + <string name="common_room_name">"Název místnosti"</string> + <string name="common_room_name_placeholder">"např. název vašeho projektu"</string> + <string name="common_search_for_someone">"Hledat někoho"</string> + <string name="common_search_results">"Výsledky hledání"</string> + <string name="common_security">"Zabezpečení"</string> + <string name="common_select_your_server">"Vyberte svůj server"</string> + <string name="common_sending">"Odesílání…"</string> + <string name="common_server_not_supported">"Server není podporován"</string> + <string name="common_server_url">"URL serveru"</string> + <string name="common_settings">"Nastavení"</string> + <string name="common_shared_location">"Sdílená poloha"</string> + <string name="common_starting_chat">"Zahajování chatu…"</string> + <string name="common_sticker">"Nálepka"</string> + <string name="common_success">"Úspěch"</string> + <string name="common_suggestions">"Návrhy"</string> + <string name="common_syncing">"Synchronizace"</string> + <string name="common_third_party_notices">"Oznámení třetích stran"</string> + <string name="common_topic">"Téma"</string> + <string name="common_topic_placeholder">"O čem je tato místnost?"</string> + <string name="common_unable_to_decrypt">"Nelze dešifrovat"</string> + <string name="common_unable_to_invite_message">"Pozvánky nebylo možné odeslat jednomu nebo více uživatelům."</string> + <string name="common_unable_to_invite_title">"Nelze odeslat pozvánky"</string> + <string name="common_unmute">"Zrušit ztlumení"</string> + <string name="common_unsupported_event">"Nepodporovaná událost"</string> + <string name="common_username">"Uživatelské jméno"</string> + <string name="common_verification_cancelled">"Ověření zrušeno"</string> + <string name="common_verification_complete">"Ověření dokončeno"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"Čekání…"</string> + <string name="dialog_title_confirmation">"Potvrzení"</string> + <string name="dialog_title_warning">"Upozornění"</string> + <string name="emoji_picker_category_activity">"Aktivity"</string> + <string name="emoji_picker_category_flags">"Vlajky"</string> + <string name="emoji_picker_category_foods">"Jídlo a nápoje"</string> + <string name="emoji_picker_category_nature">"Zvířata a příroda"</string> + <string name="emoji_picker_category_objects">"Předměty"</string> + <string name="emoji_picker_category_people">"Smajlíci a lidé"</string> + <string name="emoji_picker_category_places">"Cestování a místa"</string> + <string name="emoji_picker_category_symbols">"Symboly"</string> + <string name="error_failed_creating_the_permalink">"Vytvoření trvalého odkazu se nezdařilo"</string> + <string name="error_failed_loading_messages">"Načítání zpráv se nezdařilo"</string> + <string name="error_some_messages_have_not_been_sent">"Některé zprávy nebyly odeslány"</string> + <string name="error_unknown">"Omlouváme se, došlo k chybě"</string> + <string name="invite_friends_rich_title">"🔐️ Připojte se ke mně na %1$s"</string> + <string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás."</string> + <string name="leave_room_alert_private_subtitle">"Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit."</string> + <string name="leave_room_alert_subtitle">"Opravdu chcete opustit místnost?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d člen"</item> + <item quantity="few">"%1$d členové"</item> + <item quantity="other">"%1$d členů"</item> + </plurals> + <string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string> + <string name="rageshake_dialog_content">"Zdá se, že jste frustrovaně třásli telefonem. Chcete otevřít obrazovku pro nahlášení chyby?"</string> + <string name="report_content_explanation">"Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy."</string> + <string name="report_content_hint">"Důvod nahlášení tohoto obsahu"</string> + <string name="room_timeline_beginning_of_room">"Toto je začátek %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Toto je začátek této konverzace."</string> + <string name="room_timeline_read_marker_title">"Nové"</string> + <string name="screen_analytics_settings_share_data">"Sdílet analytická data"</string> + <string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string> + <string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string> + <string name="screen_migration_title">"Nastavení vašeho účtu"</string> + <string name="screen_report_content_block_user_hint">"Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele"</string> + <string name="screen_share_location_title">"Sdílet polohu"</string> + <string name="screen_share_my_location_action">"Sdílet moji polohu"</string> + <string name="screen_share_open_apple_maps">"Otevřít v Mapách Apple"</string> + <string name="screen_share_open_google_maps">"Otevřít v Mapách Google"</string> + <string name="screen_share_open_osm_maps">"Otevřít v OpenStreetMap"</string> + <string name="screen_share_this_location_action">"Sdílet tuto polohu"</string> + <string name="screen_view_location_title">"Poloha"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Práh detekce"</string> + <string name="settings_title_general">"Obecné"</string> + <string name="settings_version_number">"Verze: %1$s (%2$s)"</string> + <string name="test_language_identifier">"en"</string> + <string name="dialog_title_error">"Chyba"</string> + <string name="dialog_title_success">"Úspěch"</string> + <string name="screen_analytics_settings_help_us_improve">"Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy."</string> + <string name="screen_analytics_settings_read_terms">"Můžete si přečíst všechny naše podmínky %1$s."</string> + <string name="screen_analytics_settings_read_terms_content_link">"zde"</string> + <string name="screen_report_content_block_user">"Zablokovat uživatele"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml new file mode 100644 index 0000000000..10694181da --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -0,0 +1,190 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Passwort ausblenden"</string> + <string name="a11y_send_files">"Dateien senden"</string> + <string name="a11y_show_password">"Passwort anzeigen"</string> + <string name="a11y_user_menu">"Benutzermenü"</string> + <string name="action_accept">"Zustimmen"</string> + <string name="action_back">"Zurück"</string> + <string name="action_cancel">"Abbrechen"</string> + <string name="action_choose_photo">"Foto auswählen"</string> + <string name="action_clear">"Löschen"</string> + <string name="action_close">"Schließen"</string> + <string name="action_complete_verification">"Verifizierung abschließen"</string> + <string name="action_confirm">"Bestätigen"</string> + <string name="action_continue">"Weiter"</string> + <string name="action_copy">"Kopieren"</string> + <string name="action_copy_link">"Link kopieren"</string> + <string name="action_copy_link_to_message">"Link zur Nachricht kopieren"</string> + <string name="action_create">"Erstellen"</string> + <string name="action_create_a_room">"Raum erstellen"</string> + <string name="action_decline">"Ablehnen"</string> + <string name="action_disable">"Deaktivieren"</string> + <string name="action_done">"Fertig"</string> + <string name="action_edit">"Bearbeiten"</string> + <string name="action_enable">"Aktivieren"</string> + <string name="action_forgot_password">"Passwort vergessen?"</string> + <string name="action_forward">"Weiterleiten"</string> + <string name="action_invite">"Einladen"</string> + <string name="action_invite_friends">"Freunde einladen"</string> + <string name="action_invite_friends_to_app">"Freunde zu %1$s einladen"</string> + <string name="action_invite_people_to_app">"Personen zu %1$s einladen"</string> + <string name="action_invites_list">"Einladungen"</string> + <string name="action_learn_more">"Mehr erfahren"</string> + <string name="action_leave">"Verlassen"</string> + <string name="action_leave_room">"Raum verlassen"</string> + <string name="action_next">"Weiter"</string> + <string name="action_no">"Nein"</string> + <string name="action_not_now">"Nicht jetzt"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Öffne mit"</string> + <string name="action_quick_reply">"Schnellantwort"</string> + <string name="action_quote">"Zitieren"</string> + <string name="action_remove">"Entfernen"</string> + <string name="action_reply">"Antworten"</string> + <string name="action_report_bug">"Fehler melden"</string> + <string name="action_report_content">"Inhalt melden"</string> + <string name="action_retry">"Erneut versuchen"</string> + <string name="action_retry_decryption">"Entschlüsselung erneut versuchen"</string> + <string name="action_save">"Speichern"</string> + <string name="action_search">"Suchen"</string> + <string name="action_send">"Senden"</string> + <string name="action_send_message">"Nachricht senden"</string> + <string name="action_share">"Teilen"</string> + <string name="action_share_link">"Link teilen"</string> + <string name="action_skip">"Überspringen"</string> + <string name="action_start">"Starten"</string> + <string name="action_start_chat">"Chat starten"</string> + <string name="action_start_verification">"Verifizierung starten"</string> + <string name="action_static_map_load">"Tippe, um die Karte zu laden"</string> + <string name="action_take_photo">"Foto aufnehmen"</string> + <string name="action_view_source">"Quelltext anzeigen"</string> + <string name="action_yes">"Ja"</string> + <string name="common_about">"Über"</string> + <string name="common_acceptable_use_policy">"Allgemeine Geschäftsbedingungen"</string> + <string name="common_analytics">"Analyse"</string> + <string name="common_audio">"Audio"</string> + <string name="common_bubbles">"Blasen"</string> + <string name="common_copyright">"Urheberrecht"</string> + <string name="common_creating_room">"Erstelle Raum…"</string> + <string name="common_current_user_left_room">"Raum verlassen"</string> + <string name="common_decryption_error">"Entschlüsselungsfehler"</string> + <string name="common_developer_options">"Entwickleroptionen"</string> + <string name="common_edited_suffix">"(bearbeitet)"</string> + <string name="common_editing">"Bearbeiten"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Verschlüsselung aktiviert"</string> + <string name="common_error">"Fehler"</string> + <string name="common_file">"Datei"</string> + <string name="common_file_saved_on_disk_android">"Datei gespeichert unter Downloads"</string> + <string name="common_forward_message">"Nachricht weiterleiten"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Bild"</string> + <string name="common_invite_unknown_profile">"Wir können die Matrix-ID dieses Benutzers nicht validieren. Die Einladung wurde möglicherweise nicht empfangen."</string> + <string name="common_leaving_room">"Raum verlassen"</string> + <string name="common_link_copied_to_clipboard">"Link in Zwischenablage kopiert"</string> + <string name="common_loading">"Wird geladen…"</string> + <string name="common_message">"Nachricht"</string> + <string name="common_message_layout">"Nachrichtenlayout"</string> + <string name="common_message_removed">"Nachricht wurde entfernt"</string> + <string name="common_modern">"Modern"</string> + <string name="common_mute">"Stummschalten"</string> + <string name="common_no_results">"Keine Ergebnisse"</string> + <string name="common_offline">"Offline"</string> + <string name="common_password">"Passwort"</string> + <string name="common_people">"Personen"</string> + <string name="common_permalink">"Permalink"</string> + <string name="common_privacy_policy">"Datenschutzerklärung"</string> + <string name="common_reactions">"Reaktionen"</string> + <string name="common_refreshing">"Aktualisiere…"</string> + <string name="common_replying_to">"Auf %1$s antworten"</string> + <string name="common_report_a_bug">"Melde einen Fehler"</string> + <string name="common_report_submitted">"Bericht gesendet"</string> + <string name="common_room_name">"Raumname"</string> + <string name="common_room_name_placeholder">"z.B. dein Projektname"</string> + <string name="common_search_for_someone">"Suche nach jemandem"</string> + <string name="common_search_results">"Suchergebnisse"</string> + <string name="common_security">"Sicherheit"</string> + <string name="common_select_your_server">"Wählen deinen Server"</string> + <string name="common_sending">"Senden…"</string> + <string name="common_server_not_supported">"Server wird nicht unterstützt"</string> + <string name="common_server_url">"Server-URL"</string> + <string name="common_settings">"Einstellungen"</string> + <string name="common_shared_location">"Geteilter Standort"</string> + <string name="common_starting_chat">"Chat wird gestartet…"</string> + <string name="common_sticker">"Sticker"</string> + <string name="common_success">"Erfolg"</string> + <string name="common_suggestions">"Vorschläge"</string> + <string name="common_syncing">"Synchronisiere…"</string> + <string name="common_third_party_notices">"Hinweise von Drittanbietern"</string> + <string name="common_topic">"Thema"</string> + <string name="common_topic_placeholder">"Worum geht es in diesem Raum?"</string> + <string name="common_unable_to_decrypt">"Entschlüsselung nicht möglich"</string> + <string name="common_unable_to_invite_message">"Wir konnten Einladungen nicht erfolgreich an einen oder mehrere Benutzer senden."</string> + <string name="common_unable_to_invite_title">"Einladung(en) können nicht gesendet werden"</string> + <string name="common_unmute">"Stummschaltung aufheben"</string> + <string name="common_unsupported_event">"Nicht unterstütztes Ereignis"</string> + <string name="common_username">"Benutzername"</string> + <string name="common_verification_cancelled">"Verifizierung abgebrochen"</string> + <string name="common_verification_complete">"Verifizierung abgeschlossen"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"Warten…"</string> + <string name="dialog_title_confirmation">"Bestätigung"</string> + <string name="dialog_title_warning">"Warnung"</string> + <string name="emoji_picker_category_activity">"Aktivitäten"</string> + <string name="emoji_picker_category_flags">"Flaggen"</string> + <string name="emoji_picker_category_foods">"Essen & Trinken"</string> + <string name="emoji_picker_category_nature">"Tiere & Natur"</string> + <string name="emoji_picker_category_objects">"Objekte"</string> + <string name="emoji_picker_category_people">"Smileys & Personen"</string> + <string name="emoji_picker_category_places">"Reisen & Orte"</string> + <string name="emoji_picker_category_symbols">"Symbole"</string> + <string name="error_failed_creating_the_permalink">"Fehler beim Erstellen des Permalinks"</string> + <string name="error_failed_loading_map">"%1$s konnte die Karte nicht laden. Bitte versuche es später erneut."</string> + <string name="error_failed_loading_messages">"Fehler beim Laden der Nachrichten"</string> + <string name="error_failed_locating_user">"%1$s konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut."</string> + <string name="error_some_messages_have_not_been_sent">"Einige Nachrichten wurden nicht gesendet"</string> + <string name="error_unknown">"Entschuldigung, ein Fehler ist aufgetreten."</string> + <string name="invite_friends_rich_title">"🔐️ Besuchen Sie mich auf %1$s"</string> + <string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen willst? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr beitreten, auch du nicht."</string> + <string name="leave_room_alert_private_subtitle">"Bist du dir sicher, dass du den Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne eine Einladung nicht mehr beitreten."</string> + <string name="leave_room_alert_subtitle">"Bist du dir sicher, dass du den Raum verlassen möchtest?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d Mitglied"</item> + <item quantity="other">"%1$d Mitglieder"</item> + </plurals> + <string name="preference_rageshake">"Rageshake zum Melden von Fehlern"</string> + <string name="rageshake_dialog_content">"Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?"</string> + <string name="report_content_explanation">"Diese Nachricht wird an deinen Heimserver-Admin gemeldet werden. Er wird nicht in der Lage sein, verschlüsselte Nachrichten zu lesen."</string> + <string name="report_content_hint">"Grund für die Meldung dieses Inhalts"</string> + <string name="room_timeline_beginning_of_room">"Dies ist der Anfang von %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Dies ist der Beginn dieser Konversation."</string> + <string name="room_timeline_read_marker_title">"Neu"</string> + <string name="screen_analytics_settings_share_data">"Teile Analyse-Daten"</string> + <string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut."</string> + <string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string> + <string name="screen_migration_title">"Deinen Account einrichten"</string> + <string name="screen_report_content_block_user_hint">"Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest"</string> + <string name="screen_share_location_title">"Standort teilen"</string> + <string name="screen_share_my_location_action">"Meinen Standort teilen"</string> + <string name="screen_share_open_apple_maps">"In Apple Maps öffnen"</string> + <string name="screen_share_open_google_maps">"In Google Maps öffnen"</string> + <string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string> + <string name="screen_share_this_location_action">"Diesen Ort teilen"</string> + <string name="screen_view_location_title">"Standort"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string> + <string name="settings_title_general">"Allgemein"</string> + <string name="settings_version_number">"Version: %1$s (%2$s)"</string> + <string name="test_language_identifier">"de"</string> + <string name="dialog_title_error">"Fehler"</string> + <string name="dialog_title_success">"Erfolg"</string> + <string name="screen_analytics_settings_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string> + <string name="screen_analytics_settings_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string> + <string name="screen_analytics_settings_read_terms_content_link">"hier"</string> + <string name="screen_report_content_block_user">"Nutzer blockieren"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..fa8a80f953 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Ocultar contraseña"</string> + <string name="a11y_send_files">"Enviar archivos"</string> + <string name="a11y_show_password">"Mostrar contraseña"</string> + <string name="a11y_user_menu">"Menú de usuario"</string> + <string name="action_back">"Atrás"</string> + <string name="action_cancel">"Cancelar"</string> + <string name="action_clear">"Borrar"</string> + <string name="action_close">"Cerrar"</string> + <string name="action_complete_verification">"Completar verificación"</string> + <string name="action_confirm">"Confirmar"</string> + <string name="action_continue">"Continuar"</string> + <string name="action_copy">"Copiar"</string> + <string name="action_copy_link">"Copiar enlace"</string> + <string name="action_create_a_room">"Crear una sala"</string> + <string name="action_disable">"Desactivar"</string> + <string name="action_done">"Hecho"</string> + <string name="action_edit">"Editar"</string> + <string name="action_enable">"Activar"</string> + <string name="action_invite">"Invitar"</string> + <string name="action_invite_friends_to_app">"Invitar amigos a %1$s"</string> + <string name="action_learn_more">"Más información"</string> + <string name="action_leave">"Salir"</string> + <string name="action_leave_room">"Salir de la sala"</string> + <string name="action_next">"Siguiente"</string> + <string name="action_no">"No"</string> + <string name="action_not_now">"Ahora no"</string> + <string name="action_ok">"OK"</string> + <string name="action_quick_reply">"Respuesta rápida"</string> + <string name="action_quote">"Citar"</string> + <string name="action_remove">"Eliminar"</string> + <string name="action_reply">"Responder"</string> + <string name="action_report_bug">"Informar de un error"</string> + <string name="action_report_content">"Reportar Contenido"</string> + <string name="action_retry">"Reintentar"</string> + <string name="action_retry_decryption">"Reintentar descifrado"</string> + <string name="action_save">"Guardar"</string> + <string name="action_search">"Buscar"</string> + <string name="action_send">"Enviar"</string> + <string name="action_share">"Compartir"</string> + <string name="action_share_link">"Compartir enlace"</string> + <string name="action_skip">"Saltar"</string> + <string name="action_start">"Comenzar"</string> + <string name="action_start_chat">"Iniciar chat"</string> + <string name="action_start_verification">"Iniciar la verificación"</string> + <string name="action_view_source">"Ver Fuente"</string> + <string name="action_yes">"Sí"</string> + <string name="common_about">"Acerca de"</string> + <string name="common_audio">"Sonido"</string> + <string name="common_bubbles">"Burbujas"</string> + <string name="common_creating_room">"Creando sala…"</string> + <string name="common_current_user_left_room">"Saliste de la sala"</string> + <string name="common_decryption_error">"Error de descifrado"</string> + <string name="common_developer_options">"Opciones de desarrollador"</string> + <string name="common_edited_suffix">"(editado)"</string> + <string name="common_editing">"Edición"</string> + <string name="common_encryption_enabled">"Cifrado activado"</string> + <string name="common_error">"Error"</string> + <string name="common_file">"Archivo"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Imagen"</string> + <string name="common_link_copied_to_clipboard">"Enlace copiado al portapapeles"</string> + <string name="common_loading">"Cargando…"</string> + <string name="common_message">"Mensaje"</string> + <string name="common_message_layout">"Diseño del mensaje"</string> + <string name="common_message_removed">"Mensaje eliminado"</string> + <string name="common_modern">"Moderno"</string> + <string name="common_no_results">"No hay resultados"</string> + <string name="common_offline">"Sin conexión"</string> + <string name="common_password">"Contraseña"</string> + <string name="common_people">"Personas"</string> + <string name="common_permalink">"Enlace permanente"</string> + <string name="common_reactions">"Reacciones"</string> + <string name="common_replying_to">"Respondiendo a %1$s"</string> + <string name="common_report_a_bug">"Informar de un error"</string> + <string name="common_report_submitted">"Informe enviado"</string> + <string name="common_search_for_someone">"Buscar a alguien"</string> + <string name="common_security">"Seguridad"</string> + <string name="common_select_your_server">"Selecciona tu servidor"</string> + <string name="common_sending">"Enviando…"</string> + <string name="common_server_not_supported">"Servidor no compatible"</string> + <string name="common_server_url">"Dirección del servidor"</string> + <string name="common_settings">"Ajustes"</string> + <string name="common_sticker">"Sticker"</string> + <string name="common_success">"Terminado"</string> + <string name="common_suggestions">"Sugerencias"</string> + <string name="common_topic">"Tema"</string> + <string name="common_unable_to_decrypt">"No se puede descifrar"</string> + <string name="common_unsupported_event">"Evento no compatible"</string> + <string name="common_username">"Usuario"</string> + <string name="common_verification_cancelled">"Verificación cancelada"</string> + <string name="common_verification_complete">"Verificación completada"</string> + <string name="common_video">"Vídeo"</string> + <string name="common_waiting">"Esperando…"</string> + <string name="dialog_title_confirmation">"Confirmar"</string> + <string name="dialog_title_warning">"Atención"</string> + <string name="emoji_picker_category_activity">"Actividades"</string> + <string name="emoji_picker_category_flags">"Banderas"</string> + <string name="emoji_picker_category_foods">"Comida y bebida"</string> + <string name="emoji_picker_category_nature">"Animales y naturaleza"</string> + <string name="emoji_picker_category_objects">"Objetos"</string> + <string name="emoji_picker_category_people">"Emojis y personas"</string> + <string name="emoji_picker_category_places">"Viajes y lugares"</string> + <string name="emoji_picker_category_symbols">"Símbolos"</string> + <string name="error_failed_creating_the_permalink">"No se pudo crear el enlace permanente"</string> + <string name="error_failed_loading_messages">"Error al cargar mensajes"</string> + <string name="error_some_messages_have_not_been_sent">"Algunos mensajes no se han enviado"</string> + <string name="error_unknown">"Lo siento, se ha producido un error"</string> + <string name="invite_friends_text">"Hola, puedes hablar conmigo en %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú."</string> + <string name="leave_room_alert_private_subtitle">"¿Estás seguro de que quieres abandonar esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación."</string> + <string name="leave_room_alert_subtitle">"¿Seguro que quieres salir de la habitación?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d miembro"</item> + <item quantity="other">"%1$d miembros"</item> + </plurals> + <string name="preference_rageshake">"Agitar con fuerza para informar de un error"</string> + <string name="rageshake_dialog_content">"Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"</string> + <string name="report_content_explanation">"Este mensaje se notificará al administrador de su homeserver. No podrán leer ningún mensaje cifrado."</string> + <string name="report_content_hint">"Motivo para denunciar este contenido"</string> + <string name="room_timeline_beginning_of_room">"Este es el principio de %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Este es el principio de esta conversación."</string> + <string name="room_timeline_read_marker_title">"Nuevos"</string> + <string name="screen_report_content_block_user_hint">"Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string> + <string name="settings_rageshake">"Agitar con fuerza"</string> + <string name="settings_rageshake_detection_threshold">"Umbral de detección"</string> + <string name="settings_title_general">"General"</string> + <string name="settings_version_number">"Versión: %1$s (%2$s)"</string> + <string name="test_language_identifier">"es"</string> + <string name="dialog_title_error">"Error"</string> + <string name="dialog_title_success">"Terminado"</string> + <string name="screen_report_content_block_user">"Bloquear usuario"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..e3fe11dfdc --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -0,0 +1,192 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Masquer le mot de passe"</string> + <string name="a11y_send_files">"Envoyer des fichiers"</string> + <string name="a11y_show_password">"Afficher le mot de passe"</string> + <string name="a11y_user_menu">"Menu utilisateur"</string> + <string name="action_accept">"Accepter"</string> + <string name="action_back">"Retour"</string> + <string name="action_cancel">"Annuler"</string> + <string name="action_choose_photo">"Choisir une photo"</string> + <string name="action_clear">"Effacer"</string> + <string name="action_close">"Fermer"</string> + <string name="action_complete_verification">"Compléter la vérification"</string> + <string name="action_confirm">"Confirmer"</string> + <string name="action_continue">"Continuer"</string> + <string name="action_copy">"Copier"</string> + <string name="action_copy_link">"Copier le lien"</string> + <string name="action_copy_link_to_message">"Copier le lien vers le message"</string> + <string name="action_create">"Créer"</string> + <string name="action_create_a_room">"Créer un salon"</string> + <string name="action_decline">"Refuser"</string> + <string name="action_disable">"Désactiver"</string> + <string name="action_done">"Terminé"</string> + <string name="action_edit">"Modifier"</string> + <string name="action_enable">"Activer"</string> + <string name="action_forgot_password">"Mot de passe oublié ?"</string> + <string name="action_forward">"Transférer"</string> + <string name="action_invite">"Inviter"</string> + <string name="action_invite_friends">"Inviter des amis"</string> + <string name="action_invite_friends_to_app">"Inviter des amis à %1$s"</string> + <string name="action_invite_people_to_app">"Inviter des gens sur %1$s"</string> + <string name="action_invites_list">"Invitations"</string> + <string name="action_learn_more">"En savoir plus"</string> + <string name="action_leave">"Quitter"</string> + <string name="action_leave_room">"Quitter le salon"</string> + <string name="action_next">"Suivant"</string> + <string name="action_no">"Non"</string> + <string name="action_not_now">"Pas maintenant"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Ouvrir avec"</string> + <string name="action_quick_reply">"Réponse rapide"</string> + <string name="action_quote">"Citer"</string> + <string name="action_remove">"Supprimer"</string> + <string name="action_reply">"Répondre"</string> + <string name="action_report_bug">"Signaler un bug"</string> + <string name="action_report_content">"Signaler le contenu"</string> + <string name="action_retry">"Réessayer"</string> + <string name="action_retry_decryption">"Réessayer le déchiffrement"</string> + <string name="action_save">"Enregistrer"</string> + <string name="action_search">"Chercher"</string> + <string name="action_send">"Envoyer"</string> + <string name="action_send_message">"Envoyer un message"</string> + <string name="action_share">"Partager"</string> + <string name="action_share_link">"Partager le lien"</string> + <string name="action_skip">"Passer"</string> + <string name="action_start">"Démarrer"</string> + <string name="action_start_chat">"Commencer un chat"</string> + <string name="action_start_verification">"Commencer la vérification"</string> + <string name="action_static_map_load">"Touchez pour charger la carte"</string> + <string name="action_take_photo">"Prendre une photo"</string> + <string name="action_view_source">"Voir la source"</string> + <string name="action_yes">"Oui"</string> + <string name="common_about">"À propos"</string> + <string name="common_acceptable_use_policy">"Politique d’utilisation"</string> + <string name="common_analytics">"Statistiques d\'utilisation"</string> + <string name="common_audio">"Audio"</string> + <string name="common_bubbles">"Bulles"</string> + <string name="common_copyright">"Copyright"</string> + <string name="common_creating_room">"Création du salon…"</string> + <string name="common_current_user_left_room">"Le salon a été quitté"</string> + <string name="common_decryption_error">"Erreur de déchiffrement"</string> + <string name="common_developer_options">"Options de développement"</string> + <string name="common_edited_suffix">"(modifié)"</string> + <string name="common_editing">"Modification en cours"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Chiffrement activé"</string> + <string name="common_error">"Erreur"</string> + <string name="common_file">"Fichier"</string> + <string name="common_file_saved_on_disk_android">"Fichier enregistré dans les Téléchargements"</string> + <string name="common_forward_message">"Transférer le message"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Image"</string> + <string name="common_invite_unknown_profile">"Nous ne pouvons pas vérifier le Matrix ID de cet utilisateur. Cette invitation pourrait être envoyée dans le vide."</string> + <string name="common_leaving_room">"Quitter le salon"</string> + <string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string> + <string name="common_loading">"Chargement…"</string> + <string name="common_message">"Message"</string> + <string name="common_message_layout">"Mode d\'affichage des messages"</string> + <string name="common_message_removed">"Message supprimé"</string> + <string name="common_modern">"Moderne"</string> + <string name="common_mute">"Sourdine"</string> + <string name="common_no_results">"Aucun résultat"</string> + <string name="common_offline">"Hors ligne"</string> + <string name="common_password">"Mot de passe"</string> + <string name="common_people">"Personnes"</string> + <string name="common_permalink">"Permalien"</string> + <string name="common_privacy_policy">"Politique de confidentialité"</string> + <string name="common_reactions">"Réactions"</string> + <string name="common_refreshing">"Actualisation…"</string> + <string name="common_replying_to">"En réponse à %1$s"</string> + <string name="common_report_a_bug">"Signaler un problème"</string> + <string name="common_report_submitted">"Rapport envoyé"</string> + <string name="common_room_name">"Nom du salon"</string> + <string name="common_room_name_placeholder">"par exemple, le nom de votre projet"</string> + <string name="common_search_for_someone">"Rechercher quelqu\'un"</string> + <string name="common_search_results">"Résultats de la recherche"</string> + <string name="common_security">"Sécurité"</string> + <string name="common_select_your_server">"Sélectionnez votre serveur"</string> + <string name="common_sending">"Envoi en cours…"</string> + <string name="common_server_not_supported">"Serveur non pris en charge"</string> + <string name="common_server_url">"URL du serveur"</string> + <string name="common_settings">"Paramètres"</string> + <string name="common_shared_location">"Position partagée"</string> + <string name="common_starting_chat">"Démarrage du chat…"</string> + <string name="common_sticker">"Autocollant"</string> + <string name="common_success">"Succès"</string> + <string name="common_suggestions">"Suggestions"</string> + <string name="common_syncing">"Synchronisation"</string> + <string name="common_third_party_notices">"Mentions tierces"</string> + <string name="common_topic">"Sujet"</string> + <string name="common_topic_placeholder">"De quoi parle ce salon ?"</string> + <string name="common_unable_to_decrypt">"Échec de déchiffrement"</string> + <string name="common_unable_to_invite_message">"Nous n\'avons pas réussi à envoyer des invitations à un ou plusieurs utilisateurs."</string> + <string name="common_unable_to_invite_title">"Impossible d\'envoyer une ou plusieurs invitations"</string> + <string name="common_unmute">"Réactiver"</string> + <string name="common_unsupported_event">"Événement non pris en charge"</string> + <string name="common_username">"Nom d\'utilisateur"</string> + <string name="common_verification_cancelled">"Vérification annulée"</string> + <string name="common_verification_complete">"Vérification terminée"</string> + <string name="common_video">"Vidéo"</string> + <string name="common_waiting">"Patientez…"</string> + <string name="dialog_title_confirmation">"Confirmation"</string> + <string name="dialog_title_warning">"Attention"</string> + <string name="emoji_picker_category_activity">"Activités"</string> + <string name="emoji_picker_category_flags">"Drapeaux"</string> + <string name="emoji_picker_category_foods">"Nourriture et boissons"</string> + <string name="emoji_picker_category_nature">"Animaux et nature"</string> + <string name="emoji_picker_category_objects">"Objets"</string> + <string name="emoji_picker_category_people">"Émoticônes et personnes"</string> + <string name="emoji_picker_category_places">"Voyages & lieux"</string> + <string name="emoji_picker_category_symbols">"Symboles"</string> + <string name="error_failed_creating_the_permalink">"Échec de la création du permalien"</string> + <string name="error_failed_loading_map">"%1$s n’a pas pu charger la carte. Veuillez réessayer plus tard."</string> + <string name="error_failed_loading_messages">"Échec du chargement des messages"</string> + <string name="error_some_messages_have_not_been_sent">"Certains messages n\'ont pas été envoyés"</string> + <string name="error_unknown">"Désolé, une erreur est survenue."</string> + <string name="invite_friends_rich_title">"🔐️ Rejoignez-moi sur %1$s"</string> + <string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra plus rejoindre ce salon, y compris vous."</string> + <string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon n\'est pas public et vous ne pourrez pas le rejoindre sans invitation."</string> + <string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d membre"</item> + <item quantity="other">"%1$d membres"</item> + </plurals> + <string name="preference_rageshake">"Rageshake pour signaler un bug"</string> + <string name="rageshake_dialog_content">"Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"</string> + <string name="report_content_explanation">"Ce message sera signalé à l’administrateur de votre serveur d\'accueil. Ils ne pourront lire aucun message chiffré."</string> + <string name="report_content_hint">"Raison du signalement de ce contenu"</string> + <string name="room_timeline_beginning_of_room">"Ceci est le début de %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Ceci est le début de cette conversation."</string> + <string name="room_timeline_read_marker_title">"Nouveau"</string> + <string name="screen_analytics_settings_share_data">"Partager les statistiques d\'utilisation"</string> + <string name="screen_media_picker_error_failed_selection">"Impossible de sélectionner un média, veuillez réessayer."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement du média avant son envoi, veuillez réessayer."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Impossible d’envoyer le média, veuillez réessayer."</string> + <string name="screen_migration_message">"Ce processus n’a besoin d’être fait qu’une seule fois, merci de patienter."</string> + <string name="screen_migration_title">"Configuration de votre compte."</string> + <string name="screen_notification_settings_enable_notifications">"Activer les notifications sur cet appareil"</string> + <string name="screen_notification_settings_system_notifications_action_required_content_link">"paramètres système"</string> + <string name="screen_notification_settings_system_notifications_turned_off">"Notifications système désactivées"</string> + <string name="screen_notification_settings_title">"Notifications"</string> + <string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string> + <string name="screen_share_location_title">"Partage de position"</string> + <string name="screen_share_my_location_action">"Partager ma position"</string> + <string name="screen_share_open_apple_maps">"Ouvrir dans Apple Maps"</string> + <string name="screen_share_open_google_maps">"Ouvrir dans Google Maps"</string> + <string name="screen_share_open_osm_maps">"Ouvrir dans OpenStreetMap"</string> + <string name="screen_share_this_location_action">"Partager cette position"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Seuil de détection"</string> + <string name="settings_title_general">"Général"</string> + <string name="settings_version_number">"Version: %1$s ( %2$s )"</string> + <string name="test_language_identifier">"fr"</string> + <string name="dialog_title_error">"Erreur"</string> + <string name="dialog_title_success">"Succès"</string> + <string name="screen_analytics_settings_help_us_improve">"Partagez des données d\'utilisation anonymes pour nous aider à identifier les problèmes."</string> + <string name="screen_analytics_settings_read_terms">"Consultez nos conditions d\'utilisation %1$s."</string> + <string name="screen_analytics_settings_read_terms_content_link">"ici"</string> + <string name="screen_report_content_block_user">"Bloquer l\'utilisateur"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..b15d570dfc --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Nascondi password"</string> + <string name="a11y_send_files">"Invia file"</string> + <string name="a11y_show_password">"Mostra password"</string> + <string name="a11y_user_menu">"Menu utente"</string> + <string name="action_back">"Indietro"</string> + <string name="action_cancel">"Annulla"</string> + <string name="action_clear">"Cancella"</string> + <string name="action_close">"Chiudi"</string> + <string name="action_complete_verification">"Completa verifica"</string> + <string name="action_confirm">"Conferma"</string> + <string name="action_continue">"Continua"</string> + <string name="action_copy">"Copia"</string> + <string name="action_copy_link">"Copia collegamento"</string> + <string name="action_create_a_room">"Crea una stanza"</string> + <string name="action_disable">"Disabilita"</string> + <string name="action_done">"Fine"</string> + <string name="action_edit">"Modifica"</string> + <string name="action_enable">"Attiva"</string> + <string name="action_invite">"Invita"</string> + <string name="action_invite_friends_to_app">"Invita amici a %1$s"</string> + <string name="action_learn_more">"Ulteriori informazioni"</string> + <string name="action_leave">"Esci"</string> + <string name="action_leave_room">"Esci dalla stanza"</string> + <string name="action_next">"Avanti"</string> + <string name="action_no">"No"</string> + <string name="action_not_now">"Non ora"</string> + <string name="action_ok">"OK"</string> + <string name="action_quick_reply">"Risposta rapida"</string> + <string name="action_quote">"Citazione"</string> + <string name="action_remove">"Rimuovi"</string> + <string name="action_reply">"Rispondi"</string> + <string name="action_report_bug">"Segnala un problema"</string> + <string name="action_report_content">"Segnala Contenuto"</string> + <string name="action_retry">"Riprova"</string> + <string name="action_retry_decryption">"Riprova la decrittazione"</string> + <string name="action_save">"Salva"</string> + <string name="action_search">"Ricerca"</string> + <string name="action_send">"Invia"</string> + <string name="action_share">"Condividi"</string> + <string name="action_share_link">"Condividi collegamento"</string> + <string name="action_skip">"Salta"</string> + <string name="action_start">"Inizia"</string> + <string name="action_start_chat">"Avvia conversazione"</string> + <string name="action_start_verification">"Avvia la verifica"</string> + <string name="action_view_source">"Vedi Sorgente"</string> + <string name="action_yes">"Sì"</string> + <string name="common_about">"Informazioni"</string> + <string name="common_audio">"Audio"</string> + <string name="common_bubbles">"Fumetti"</string> + <string name="common_creating_room">"Creazione stanza…"</string> + <string name="common_current_user_left_room">"Hai lasciato la stanza"</string> + <string name="common_decryption_error">"Errore di decrittazione"</string> + <string name="common_developer_options">"Opzioni sviluppatore"</string> + <string name="common_edited_suffix">"(modificato)"</string> + <string name="common_editing">"Modifica in corso"</string> + <string name="common_encryption_enabled">"Crittografia abilitata"</string> + <string name="common_error">"Errore"</string> + <string name="common_file">"File"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Immagine"</string> + <string name="common_link_copied_to_clipboard">"Collegamento copiato negli appunti"</string> + <string name="common_loading">"Caricamento…"</string> + <string name="common_message">"Messaggio"</string> + <string name="common_message_layout">"Layout del messaggio"</string> + <string name="common_message_removed">"Messaggio rimosso"</string> + <string name="common_modern">"Moderno"</string> + <string name="common_no_results">"Nessun risultato"</string> + <string name="common_offline">"Non in linea"</string> + <string name="common_password">"Password"</string> + <string name="common_people">"Persone"</string> + <string name="common_permalink">"Collegamento permanente"</string> + <string name="common_reactions">"Reazioni"</string> + <string name="common_replying_to">"Risposta a %1$s"</string> + <string name="common_report_a_bug">"Segnala un problema"</string> + <string name="common_report_submitted">"Segnalazione inviata"</string> + <string name="common_search_for_someone">"Cerca qualcuno"</string> + <string name="common_security">"Sicurezza"</string> + <string name="common_select_your_server">"Seleziona il tuo server"</string> + <string name="common_sending">"Invio in corso…"</string> + <string name="common_server_not_supported">"Server non supportato"</string> + <string name="common_server_url">"URL del server"</string> + <string name="common_settings">"Impostazioni"</string> + <string name="common_sticker">"Adesivo"</string> + <string name="common_success">"Operazione riuscita"</string> + <string name="common_suggestions">"Suggerimenti"</string> + <string name="common_topic">"Oggetto"</string> + <string name="common_unable_to_decrypt">"Impossibile decrittografare"</string> + <string name="common_unsupported_event">"Evento non supportato"</string> + <string name="common_username">"Nome utente"</string> + <string name="common_verification_cancelled">"Verifica annullata"</string> + <string name="common_verification_complete">"Verifica completata"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"In attesa…"</string> + <string name="dialog_title_confirmation">"Conferma"</string> + <string name="dialog_title_warning">"Attenzione"</string> + <string name="emoji_picker_category_activity">"Attività"</string> + <string name="emoji_picker_category_flags">"Bandiere"</string> + <string name="emoji_picker_category_foods">"Cibi & Bevande"</string> + <string name="emoji_picker_category_nature">"Animali & Natura"</string> + <string name="emoji_picker_category_objects">"Oggetti"</string> + <string name="emoji_picker_category_people">"Faccine & Persone"</string> + <string name="emoji_picker_category_places">"Viaggi & Luoghi"</string> + <string name="emoji_picker_category_symbols">"Simboli"</string> + <string name="error_failed_creating_the_permalink">"Impossibile creare il collegamento permanente"</string> + <string name="error_failed_loading_messages">"Caricamento dei messaggi non riuscito"</string> + <string name="error_some_messages_have_not_been_sent">"Alcuni messaggi non sono stati inviati"</string> + <string name="error_unknown">"Siamo spiacenti, si è verificato un errore"</string> + <string name="invite_friends_text">"Ehi, parlami su %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso."</string> + <string name="leave_room_alert_private_subtitle">"Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito."</string> + <string name="leave_room_alert_subtitle">"Sei sicuro di voler lasciare la stanza?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d membro"</item> + <item quantity="other">"%1$d membri"</item> + </plurals> + <string name="preference_rageshake">"Scuoti per segnalare un problema"</string> + <string name="rageshake_dialog_content">"Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"</string> + <string name="report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi criptati."</string> + <string name="report_content_hint">"Motivo della segnalazione di questo contenuto"</string> + <string name="room_timeline_beginning_of_room">"Questo è l\'inizio di %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Questo è l\'inizio della conversazione."</string> + <string name="room_timeline_read_marker_title">"Nuovo"</string> + <string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Soglia di rilevamento"</string> + <string name="settings_title_general">"Generali"</string> + <string name="settings_version_number">"Versione: %1$s (%2$s)"</string> + <string name="test_language_identifier">"it"</string> + <string name="dialog_title_error">"Errore"</string> + <string name="dialog_title_success">"Operazione riuscita"</string> + <string name="screen_report_content_block_user">"Blocca utente"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..d90a7a6cfc --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -0,0 +1,184 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Ascundeți parola"</string> + <string name="a11y_send_files">"Trimiteți fișiere"</string> + <string name="a11y_show_password">"Afișați parola"</string> + <string name="a11y_user_menu">"Meniu utilizator"</string> + <string name="action_accept">"Acceptați"</string> + <string name="action_back">"Înapoi"</string> + <string name="action_cancel">"Anulați"</string> + <string name="action_choose_photo">"Alegeți o fotografie"</string> + <string name="action_clear">"Ștergeți"</string> + <string name="action_close">"Închideți"</string> + <string name="action_complete_verification">"Verificare completă"</string> + <string name="action_confirm">"Confirmați"</string> + <string name="action_continue">"Continuați"</string> + <string name="action_copy">"Copiați"</string> + <string name="action_copy_link">"Copiați linkul"</string> + <string name="action_copy_link_to_message">"Copiați linkul către mesaj"</string> + <string name="action_create">"Creați"</string> + <string name="action_create_a_room">"Creați o cameră"</string> + <string name="action_decline">"Refuzați"</string> + <string name="action_disable">"Dezactivați"</string> + <string name="action_done">"Efectuat"</string> + <string name="action_edit">"Editați"</string> + <string name="action_enable">"Activați"</string> + <string name="action_forgot_password">"Ați uitat parola?"</string> + <string name="action_forward">"Redirecționați"</string> + <string name="action_invite">"Invitați"</string> + <string name="action_invite_friends">"Invitați prieteni"</string> + <string name="action_invite_friends_to_app">"Invitați prieteni în %1$s"</string> + <string name="action_invite_people_to_app">"Invitați persoane la %1$s"</string> + <string name="action_invites_list">"Invitații"</string> + <string name="action_learn_more">"Aflați mai multe"</string> + <string name="action_leave">"Părăsiți"</string> + <string name="action_leave_room">"Părăsiți camera"</string> + <string name="action_next">"Următorul"</string> + <string name="action_no">"Nu"</string> + <string name="action_not_now">"Nu acum"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Deschideți cu"</string> + <string name="action_quick_reply">"Raspuns rapid"</string> + <string name="action_quote">"Citat"</string> + <string name="action_remove">"Ștergeți"</string> + <string name="action_reply">"Răspundeți"</string> + <string name="action_report_bug">"Raportați o eroare"</string> + <string name="action_report_content">"Raportați conținutul"</string> + <string name="action_retry">"Reîncercați"</string> + <string name="action_retry_decryption">"Reîncercați decriptarea"</string> + <string name="action_save">"Salvați"</string> + <string name="action_search">"Căutați"</string> + <string name="action_send">"Trimiteți"</string> + <string name="action_send_message">"Trimiteți mesajul"</string> + <string name="action_share">"Partajați"</string> + <string name="action_share_link">"Partajați linkul"</string> + <string name="action_skip">"Omiteți"</string> + <string name="action_start">"Începeți"</string> + <string name="action_start_chat">"Începeți discuția"</string> + <string name="action_start_verification">"Începeți verificarea"</string> + <string name="action_static_map_load">"Atingeți pentru a încărca harta"</string> + <string name="action_take_photo">"Faceți o fotografie"</string> + <string name="action_view_source">"Vedeți sursă"</string> + <string name="action_yes">"Da"</string> + <string name="common_about">"Despre"</string> + <string name="common_acceptable_use_policy">"Politică de utilizare rezonabilă"</string> + <string name="common_analytics">"Analitice"</string> + <string name="common_audio">"Audio"</string> + <string name="common_bubbles">"Baloane"</string> + <string name="common_copyright">"Drepturi de autor"</string> + <string name="common_creating_room">"Se creează camera…"</string> + <string name="common_current_user_left_room">"Ați parăsit camera"</string> + <string name="common_decryption_error">"Eroare de decriptare"</string> + <string name="common_developer_options">"Opțiuni programator"</string> + <string name="common_edited_suffix">"(editat)"</string> + <string name="common_editing">"Editare"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Criptare activată"</string> + <string name="common_error">"Eroare"</string> + <string name="common_file">"Fişier"</string> + <string name="common_file_saved_on_disk_android">"Fișier salvat în Descărcări"</string> + <string name="common_forward_message">"Redirecționați mesajul"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Imagine"</string> + <string name="common_invite_unknown_profile">"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost trimisă."</string> + <string name="common_leaving_room">"Se părăsește conversația"</string> + <string name="common_link_copied_to_clipboard">"Linkul a fost copiat în clipboard"</string> + <string name="common_loading">"Se încarcă…"</string> + <string name="common_message">"Mesaj"</string> + <string name="common_message_layout">"Aranjamentul mesajelor"</string> + <string name="common_message_removed">"Mesaj sters"</string> + <string name="common_modern">"Modern"</string> + <string name="common_mute">"Dezactivați sunetul"</string> + <string name="common_no_results">"Niciun rezultat"</string> + <string name="common_offline">"Deconectat"</string> + <string name="common_password">"Parola"</string> + <string name="common_people">"Persoane"</string> + <string name="common_permalink">"Permalink"</string> + <string name="common_privacy_policy">"Politica de confidențialitate"</string> + <string name="common_reactions">"Reacții"</string> + <string name="common_refreshing">"Se actualizează"</string> + <string name="common_replying_to">"Răspuns pentru %1$s"</string> + <string name="common_report_a_bug">"Raportați o eroare"</string> + <string name="common_report_submitted">"Raport trimis"</string> + <string name="common_room_name">"Numele camerei"</string> + <string name="common_room_name_placeholder">"de exemplu, numele proiectului dvs."</string> + <string name="common_search_for_someone">"Căutați pe cineva"</string> + <string name="common_search_results">"Rezultatele căutării"</string> + <string name="common_security">"Securitate"</string> + <string name="common_select_your_server">"Selectați serverul"</string> + <string name="common_sending">"Se trimite…"</string> + <string name="common_server_not_supported">"Serverul nu este compatibil"</string> + <string name="common_server_url">"Adresa URL a serverului"</string> + <string name="common_settings">"Setări"</string> + <string name="common_shared_location">"Locație partajată"</string> + <string name="common_starting_chat">"Se începe conversația…"</string> + <string name="common_sticker">"Autocolant"</string> + <string name="common_success">"Succes"</string> + <string name="common_suggestions">"Sugestii"</string> + <string name="common_syncing">"Se sincronizează…"</string> + <string name="common_third_party_notices">"Notificări despre software de la terți"</string> + <string name="common_topic">"Subiect"</string> + <string name="common_topic_placeholder">"Despre ce este vorba în această cameră?"</string> + <string name="common_unable_to_decrypt">"Nu s-a putut decripta"</string> + <string name="common_unable_to_invite_message">"Nu am putut trimite cu succes invitații unuia sau mai multor utilizatori."</string> + <string name="common_unable_to_invite_title">"Nu s-a putut trimite invitația (invitațiile)"</string> + <string name="common_unmute">"Activați sunetul"</string> + <string name="common_unsupported_event">"Eveniment neacceptat"</string> + <string name="common_username">"Utilizator"</string> + <string name="common_verification_cancelled">"Verificare anulată"</string> + <string name="common_verification_complete">"Verificare completă"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"Se aşteaptă…"</string> + <string name="dialog_title_confirmation">"Confirmare"</string> + <string name="dialog_title_warning">"Avertisment"</string> + <string name="emoji_picker_category_activity">"Activități"</string> + <string name="emoji_picker_category_flags">"Steaguri"</string> + <string name="emoji_picker_category_foods">"Mâncare & Băutură"</string> + <string name="emoji_picker_category_nature">"Animale și Natură"</string> + <string name="emoji_picker_category_objects">"Obiecte"</string> + <string name="emoji_picker_category_people">"Fețe zâmbitoare & Oameni"</string> + <string name="emoji_picker_category_places">"Călătorii & Locuri"</string> + <string name="emoji_picker_category_symbols">"Simboluri"</string> + <string name="error_failed_creating_the_permalink">"Crearea permalink-ului a eșuat"</string> + <string name="error_failed_loading_messages">"Încărcarea mesajelor a eșuat"</string> + <string name="error_some_messages_have_not_been_sent">"Unele mesaje nu au fost trimise"</string> + <string name="error_unknown">"Ne pare rău, a apărut o eroare"</string> + <string name="invite_friends_rich_title">"🔐️ Alăturați-vă mie pe %1$s"</string> + <string name="invite_friends_text">"Hei, vorbește cu mine pe %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra."</string> + <string name="leave_room_alert_private_subtitle">"Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație."</string> + <string name="leave_room_alert_subtitle">"Sunteți sigur că vreți să părăsiți camera?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d membru"</item> + <item quantity="few">"%1$d membri"</item> + <item quantity="other">"%1$d membri"</item> + </plurals> + <string name="preference_rageshake">"Rageshake pentru a raporta erori"</string> + <string name="rageshake_dialog_content">"Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"</string> + <string name="report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string> + <string name="report_content_hint">"Motivul raportării acestui conținut"</string> + <string name="room_timeline_beginning_of_room">"Acesta este începutul conversației %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Acesta este începutul acestei conversații."</string> + <string name="room_timeline_read_marker_title">"Nou"</string> + <string name="screen_analytics_settings_share_data">"Partajați datele analitice"</string> + <string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Încărcarea fișierelor media a eșuat, încercați din nou."</string> + <string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string> + <string name="screen_share_location_title">"Partajați locația"</string> + <string name="screen_share_my_location_action">"Distribuiți locația mea"</string> + <string name="screen_share_this_location_action">"Distribuiți această locație"</string> + <string name="screen_view_location_title">"Locație"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Prag de detecție"</string> + <string name="settings_title_general">"General"</string> + <string name="settings_version_number">"Versiunea: %1$s (%2$s)"</string> + <string name="test_language_identifier">"ro"</string> + <string name="dialog_title_error">"Eroare"</string> + <string name="dialog_title_success">"Succes"</string> + <string name="screen_analytics_settings_help_us_improve">"Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme."</string> + <string name="screen_analytics_settings_read_terms">"Puteți citi toate condițiile noastre %1$s."</string> + <string name="screen_analytics_settings_read_terms_content_link">"aici"</string> + <string name="screen_report_content_block_user">"Blocați utilizatorul"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..654f3d7cbe --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -0,0 +1,196 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Skryť heslo"</string> + <string name="a11y_send_files">"Odoslať súbory"</string> + <string name="a11y_show_password">"Zobraziť heslo"</string> + <string name="a11y_user_menu">"Používateľské menu"</string> + <string name="action_accept">"Prijať"</string> + <string name="action_back">"Späť"</string> + <string name="action_cancel">"Zrušiť"</string> + <string name="action_choose_photo">"Vybrať fotku"</string> + <string name="action_clear">"Vyčistiť"</string> + <string name="action_close">"Zavrieť"</string> + <string name="action_complete_verification">"Dokončiť overenie"</string> + <string name="action_confirm">"Potvrdiť"</string> + <string name="action_continue">"Pokračovať"</string> + <string name="action_copy">"Kopírovať"</string> + <string name="action_copy_link">"Kopírovať odkaz"</string> + <string name="action_copy_link_to_message">"Kopírovať odkaz do správy"</string> + <string name="action_create">"Vytvoriť"</string> + <string name="action_create_a_room">"Vytvoriť miestnosť"</string> + <string name="action_decline">"Odmietnuť"</string> + <string name="action_disable">"Vypnúť"</string> + <string name="action_done">"Hotovo"</string> + <string name="action_edit">"Upraviť"</string> + <string name="action_enable">"Povoliť"</string> + <string name="action_forgot_password">"Zabudnuté heslo?"</string> + <string name="action_forward">"Preposlať"</string> + <string name="action_invite">"Pozvať"</string> + <string name="action_invite_friends">"Pozvať priateľov"</string> + <string name="action_invite_friends_to_app">"Pozvať priateľov do %1$s"</string> + <string name="action_invite_people_to_app">"Pozvať ľudí do %1$s"</string> + <string name="action_invites_list">"Pozvánky"</string> + <string name="action_learn_more">"Zistiť viac"</string> + <string name="action_leave">"Opustiť"</string> + <string name="action_leave_room">"Opustiť miestnosť"</string> + <string name="action_next">"Ďalej"</string> + <string name="action_no">"Nie"</string> + <string name="action_not_now">"Teraz nie"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Otvoriť pomocou"</string> + <string name="action_quick_reply">"Rýchla odpoveď"</string> + <string name="action_quote">"Citovať"</string> + <string name="action_remove">"Odstrániť"</string> + <string name="action_reply">"Odpovedať"</string> + <string name="action_report_bug">"Nahlásiť chybu"</string> + <string name="action_report_content">"Nahlásiť obsah"</string> + <string name="action_retry">"Skúsiť znova"</string> + <string name="action_retry_decryption">"Opakovať dešifrovanie"</string> + <string name="action_save">"Uložiť"</string> + <string name="action_search">"Hľadať"</string> + <string name="action_send">"Odoslať"</string> + <string name="action_send_message">"Odoslať správu"</string> + <string name="action_share">"Zdieľať"</string> + <string name="action_share_link">"Zdieľať odkaz"</string> + <string name="action_skip">"Preskočiť"</string> + <string name="action_start">"Spustiť"</string> + <string name="action_start_chat">"Začať konverzáciu"</string> + <string name="action_start_verification">"Spustiť overovanie"</string> + <string name="action_static_map_load">"Ťuknutím načítate mapu"</string> + <string name="action_take_photo">"Urobiť fotku"</string> + <string name="action_view_source">"Zobraziť zdroj"</string> + <string name="action_yes">"Áno"</string> + <string name="common_about">"O aplikácii"</string> + <string name="common_acceptable_use_policy">"Zásady prijateľného používania"</string> + <string name="common_analytics">"Analytika"</string> + <string name="common_audio">"Zvuk"</string> + <string name="common_bubbles">"Bubliny"</string> + <string name="common_copyright">"Autorské práva"</string> + <string name="common_creating_room">"Vytváranie miestnosti…"</string> + <string name="common_current_user_left_room">"Opustil/a miestnosť"</string> + <string name="common_decryption_error">"Chyba dešifrovania"</string> + <string name="common_developer_options">"Možnosti pre vývojárov"</string> + <string name="common_edited_suffix">"(upravené)"</string> + <string name="common_editing">"Upravuje sa"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Šifrovanie zapnuté"</string> + <string name="common_error">"Chyba"</string> + <string name="common_file">"Súbor"</string> + <string name="common_file_saved_on_disk_android">"Súbor bol uložený do priečinka Stiahnuté súbory"</string> + <string name="common_forward_message">"Preposlať správu"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Obrázok"</string> + <string name="common_invite_unknown_profile">"Toto Matrix ID sa nedá nájsť, takže pozvánka nemusí byť prijatá."</string> + <string name="common_leaving_room">"Opustenie miestnosti"</string> + <string name="common_link_copied_to_clipboard">"Odkaz bol skopírovaný do schránky"</string> + <string name="common_loading">"Načítava sa…"</string> + <string name="common_message">"Správa"</string> + <string name="common_message_layout">"Rozloženie správy"</string> + <string name="common_message_removed">"Správa odstránená"</string> + <string name="common_modern">"Moderné"</string> + <string name="common_mute">"Stlmiť"</string> + <string name="common_no_results">"Žiadne výsledky"</string> + <string name="common_offline">"Offline"</string> + <string name="common_password">"Heslo"</string> + <string name="common_people">"Ľudia"</string> + <string name="common_permalink">"Trvalý odkaz"</string> + <string name="common_privacy_policy">"Zásady ochrany osobných údajov"</string> + <string name="common_reactions">"Reakcie"</string> + <string name="common_refreshing">"Obnovuje sa…"</string> + <string name="common_replying_to">"Odpoveď na %1$s"</string> + <string name="common_report_a_bug">"Nahlásiť chybu"</string> + <string name="common_report_submitted">"Nahlásenie bolo odoslané"</string> + <string name="common_room_name">"Názov miestnosti"</string> + <string name="common_room_name_placeholder">"napr. názov vášho projektu"</string> + <string name="common_search_for_someone">"Vyhľadať niekoho"</string> + <string name="common_search_results">"Výsledky hľadania"</string> + <string name="common_security">"Bezpečnosť"</string> + <string name="common_select_your_server">"Vyberte svoj server"</string> + <string name="common_sending">"Odosiela sa…"</string> + <string name="common_server_not_supported">"Server nie je podporovaný"</string> + <string name="common_server_url">"URL adresa servera"</string> + <string name="common_settings">"Nastavenia"</string> + <string name="common_shared_location">"Zdieľaná poloha"</string> + <string name="common_starting_chat">"Spustenie konverzácie…"</string> + <string name="common_sticker">"Nálepka"</string> + <string name="common_success">"Úspech"</string> + <string name="common_suggestions">"Návrhy"</string> + <string name="common_syncing">"Synchronizuje sa"</string> + <string name="common_third_party_notices">"Oznámenia tretích strán"</string> + <string name="common_topic">"Téma"</string> + <string name="common_topic_placeholder">"O čom je táto miestnosť?"</string> + <string name="common_unable_to_decrypt">"Nie je možné dešifrovať"</string> + <string name="common_unable_to_invite_message">"Pozvánky nebolo možné odoslať jednému alebo viacerým používateľom."</string> + <string name="common_unable_to_invite_title">"Nie je možné odoslať pozvánku/ky"</string> + <string name="common_unmute">"Zrušiť stlmenie zvuku"</string> + <string name="common_unsupported_event">"Nepodporovaná udalosť"</string> + <string name="common_username">"Používateľské meno"</string> + <string name="common_verification_cancelled">"Overovanie zrušené"</string> + <string name="common_verification_complete">"Overovanie je dokončené"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"Čaká sa…"</string> + <string name="dialog_title_confirmation">"Potvrdenie"</string> + <string name="dialog_title_warning">"Upozornenie"</string> + <string name="emoji_picker_category_activity">"Aktivity"</string> + <string name="emoji_picker_category_flags">"Vlajky"</string> + <string name="emoji_picker_category_foods">"Jedlo a nápoje"</string> + <string name="emoji_picker_category_nature">"Zvieratá a príroda"</string> + <string name="emoji_picker_category_objects">"Predmety"</string> + <string name="emoji_picker_category_people">"Smajlíky a ľudia"</string> + <string name="emoji_picker_category_places">"Cestovanie a miesta"</string> + <string name="emoji_picker_category_symbols">"Symboly"</string> + <string name="error_failed_creating_the_permalink">"Nepodarilo sa vytvoriť trvalý odkaz"</string> + <string name="error_failed_loading_map">"%1$s nedokázal načítať mapu. Skúste to prosím neskôr."</string> + <string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string> + <string name="error_failed_locating_user">"%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr."</string> + <string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string> + <string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string> + <string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string> + <string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string> + <string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string> + <string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d člen"</item> + <item quantity="few">"%1$d členovia"</item> + <item quantity="other">"%1$d členov"</item> + </plurals> + <string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string> + <string name="rageshake_dialog_content">"Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?"</string> + <string name="report_content_explanation">"Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy."</string> + <string name="report_content_hint">"Dôvod nahlásenia tohto obsahu"</string> + <string name="room_timeline_beginning_of_room">"Toto je začiatok %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"Toto je začiatok tejto konverzácie."</string> + <string name="room_timeline_read_marker_title">"Nové"</string> + <string name="screen_analytics_settings_share_data">"Zdieľať analytické údaje"</string> + <string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string> + <string name="screen_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string> + <string name="screen_migration_title">"Nastavenie vášho účtu."</string> + <string name="screen_notification_settings_enable_notifications">"Povoliť oznámenia na tomto zariadení"</string> + <string name="screen_notification_settings_system_notifications_action_required">"Ak chcete dostávať oznámenia, zmeňte prosím svoje %1$s."</string> + <string name="screen_notification_settings_system_notifications_action_required_content_link">"nastavenia systému"</string> + <string name="screen_notification_settings_system_notifications_turned_off">"Systémové oznámenia sú vypnuté"</string> + <string name="screen_notification_settings_title">"Oznámenia"</string> + <string name="screen_report_content_block_user_hint">"Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa"</string> + <string name="screen_share_location_title">"Zdieľať polohu"</string> + <string name="screen_share_my_location_action">"Zdieľať moju polohu"</string> + <string name="screen_share_open_apple_maps">"Otvoriť v Apple Maps"</string> + <string name="screen_share_open_google_maps">"Otvoriť v Mapách Google"</string> + <string name="screen_share_open_osm_maps">"Otvoriť v OpenStreetMap"</string> + <string name="screen_share_this_location_action">"Zdieľajte túto polohu"</string> + <string name="screen_view_location_title">"Poloha"</string> + <string name="settings_rageshake">"Zúrivé potrasenie"</string> + <string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string> + <string name="settings_title_general">"Všeobecné"</string> + <string name="settings_version_number">"Verzia: %1$s (%2$s)"</string> + <string name="test_language_identifier">"sk"</string> + <string name="dialog_title_error">"Chyba"</string> + <string name="dialog_title_success">"Úspech"</string> + <string name="screen_analytics_settings_help_us_improve">"Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy."</string> + <string name="screen_analytics_settings_read_terms">"Môžete si prečítať všetky naše podmienky %1$s."</string> + <string name="screen_analytics_settings_read_terms_content_link">"tu"</string> + <string name="screen_report_content_block_user">"Zablokovať používateľa"</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values/donottranslate.xml b/libraries/ui-strings/src/main/res/values/donottranslate.xml new file mode 100755 index 0000000000..910ce31c41 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values/donottranslate.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <string name="ellipsis" translatable="false">…</string> + <string name="no_value_placeholder" translatable="false">–</string> + + <!-- Temporary string --> + <string name="not_implemented" translatable="false">Not implemented yet in ${app_name}</string> + + <!-- onboarding english only word play --> + <string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string> + + <string name="command_description_crash_application" translatable="false">Crash the application.</string> + + <!-- WIP --> + <string name="location_map_view_copyright" translatable="false">© MapTiler © OpenStreetMap contributors</string> +</resources> diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..10952f4194 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -0,0 +1,198 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <string name="a11y_hide_password">"Hide password"</string> + <string name="a11y_send_files">"Send files"</string> + <string name="a11y_show_password">"Show password"</string> + <string name="a11y_user_menu">"User menu"</string> + <string name="action_accept">"Accept"</string> + <string name="action_back">"Back"</string> + <string name="action_cancel">"Cancel"</string> + <string name="action_choose_photo">"Choose photo"</string> + <string name="action_clear">"Clear"</string> + <string name="action_close">"Close"</string> + <string name="action_complete_verification">"Complete verification"</string> + <string name="action_confirm">"Confirm"</string> + <string name="action_continue">"Continue"</string> + <string name="action_copy">"Copy"</string> + <string name="action_copy_link">"Copy link"</string> + <string name="action_copy_link_to_message">"Copy link to message"</string> + <string name="action_create">"Create"</string> + <string name="action_create_a_room">"Create a room"</string> + <string name="action_decline">"Decline"</string> + <string name="action_disable">"Disable"</string> + <string name="action_done">"Done"</string> + <string name="action_edit">"Edit"</string> + <string name="action_enable">"Enable"</string> + <string name="action_forgot_password">"Forgot password?"</string> + <string name="action_forward">"Forward"</string> + <string name="action_invite">"Invite"</string> + <string name="action_invite_friends">"Invite friends"</string> + <string name="action_invite_friends_to_app">"Invite friends to %1$s"</string> + <string name="action_invite_people_to_app">"Invite people to %1$s"</string> + <string name="action_invites_list">"Invites"</string> + <string name="action_learn_more">"Learn more"</string> + <string name="action_leave">"Leave"</string> + <string name="action_leave_room">"Leave room"</string> + <string name="action_next">"Next"</string> + <string name="action_no">"No"</string> + <string name="action_not_now">"Not now"</string> + <string name="action_ok">"OK"</string> + <string name="action_open_with">"Open with"</string> + <string name="action_quick_reply">"Quick reply"</string> + <string name="action_quote">"Quote"</string> + <string name="action_remove">"Remove"</string> + <string name="action_reply">"Reply"</string> + <string name="action_report_bug">"Report bug"</string> + <string name="action_report_content">"Report Content"</string> + <string name="action_retry">"Retry"</string> + <string name="action_retry_decryption">"Retry decryption"</string> + <string name="action_save">"Save"</string> + <string name="action_search">"Search"</string> + <string name="action_send">"Send"</string> + <string name="action_send_message">"Send message"</string> + <string name="action_share">"Share"</string> + <string name="action_share_link">"Share link"</string> + <string name="action_skip">"Skip"</string> + <string name="action_start">"Start"</string> + <string name="action_start_chat">"Start chat"</string> + <string name="action_start_verification">"Start verification"</string> + <string name="action_static_map_load">"Tap to load map"</string> + <string name="action_take_photo">"Take photo"</string> + <string name="action_view_source">"View Source"</string> + <string name="action_yes">"Yes"</string> + <string name="common_about">"About"</string> + <string name="common_acceptable_use_policy">"Acceptable use policy"</string> + <string name="common_analytics">"Analytics"</string> + <string name="common_audio">"Audio"</string> + <string name="common_bubbles">"Bubbles"</string> + <string name="common_copyright">"Copyright"</string> + <string name="common_creating_room">"Creating room…"</string> + <string name="common_current_user_left_room">"Left room"</string> + <string name="common_decryption_error">"Decryption error"</string> + <string name="common_developer_options">"Developer options"</string> + <string name="common_edited_suffix">"(edited)"</string> + <string name="common_editing">"Editing"</string> + <string name="common_emote">"* %1$s %2$s"</string> + <string name="common_encryption_enabled">"Encryption enabled"</string> + <string name="common_error">"Error"</string> + <string name="common_file">"File"</string> + <string name="common_file_saved_on_disk_android">"File saved to Downloads"</string> + <string name="common_forward_message">"Forward message"</string> + <string name="common_gif">"GIF"</string> + <string name="common_image">"Image"</string> + <string name="common_invite_unknown_profile">"This Matrix ID can\'t be found, so the invite might not be received."</string> + <string name="common_leaving_room">"Leaving room"</string> + <string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string> + <string name="common_loading">"Loading…"</string> + <string name="common_message">"Message"</string> + <string name="common_message_layout">"Message layout"</string> + <string name="common_message_removed">"Message removed"</string> + <string name="common_modern">"Modern"</string> + <string name="common_mute">"Mute"</string> + <string name="common_no_results">"No results"</string> + <string name="common_offline">"Offline"</string> + <string name="common_password">"Password"</string> + <string name="common_people">"People"</string> + <string name="common_permalink">"Permalink"</string> + <string name="common_privacy_policy">"Privacy policy"</string> + <string name="common_reactions">"Reactions"</string> + <string name="common_refreshing">"Refreshing…"</string> + <string name="common_replying_to">"Replying to %1$s"</string> + <string name="common_report_a_bug">"Report a bug"</string> + <string name="common_report_submitted">"Report submitted"</string> + <string name="common_room_name">"Room name"</string> + <string name="common_room_name_placeholder">"e.g. your project name"</string> + <string name="common_search_for_someone">"Search for someone"</string> + <string name="common_search_results">"Search results"</string> + <string name="common_security">"Security"</string> + <string name="common_select_your_server">"Select your server"</string> + <string name="common_sending">"Sending…"</string> + <string name="common_server_not_supported">"Server not supported"</string> + <string name="common_server_url">"Server URL"</string> + <string name="common_settings">"Settings"</string> + <string name="common_shared_location">"Shared location"</string> + <string name="common_starting_chat">"Starting chat…"</string> + <string name="common_sticker">"Sticker"</string> + <string name="common_success">"Success"</string> + <string name="common_suggestions">"Suggestions"</string> + <string name="common_syncing">"Syncing"</string> + <string name="common_third_party_notices">"Third-party notices"</string> + <string name="common_topic">"Topic"</string> + <string name="common_topic_placeholder">"What is this room about?"</string> + <string name="common_unable_to_decrypt">"Unable to decrypt"</string> + <string name="common_unable_to_invite_message">"Invites couldn\'t be sent to one or more users."</string> + <string name="common_unable_to_invite_title">"Unable to send invite(s)"</string> + <string name="common_unmute">"Unmute"</string> + <string name="common_unsupported_event">"Unsupported event"</string> + <string name="common_username">"Username"</string> + <string name="common_verification_cancelled">"Verification cancelled"</string> + <string name="common_verification_complete">"Verification complete"</string> + <string name="common_video">"Video"</string> + <string name="common_waiting">"Waiting…"</string> + <string name="dialog_title_confirmation">"Confirmation"</string> + <string name="dialog_title_warning">"Warning"</string> + <string name="emoji_picker_category_activity">"Activities"</string> + <string name="emoji_picker_category_flags">"Flags"</string> + <string name="emoji_picker_category_foods">"Food & Drink"</string> + <string name="emoji_picker_category_nature">"Animals & Nature"</string> + <string name="emoji_picker_category_objects">"Objects"</string> + <string name="emoji_picker_category_people">"Smileys & People"</string> + <string name="emoji_picker_category_places">"Travel & Places"</string> + <string name="emoji_picker_category_symbols">"Symbols"</string> + <string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string> + <string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string> + <string name="error_failed_loading_messages">"Failed loading messages"</string> + <string name="error_failed_locating_user">"%1$s could not access your location. Please try again later."</string> + <string name="error_missing_location_auth_android">"To send a location, allow %1$s to access your location from its settings screen."</string> + <string name="error_missing_location_rationale_android">"To send a location, allow %1$s to access your location in the next dialog."</string> + <string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string> + <string name="error_unknown">"Sorry, an error occurred"</string> + <string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string> + <string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string> + <string name="leave_room_alert_empty_subtitle">"Are you sure that you want to leave this room? You\'re the only person here. If you leave, no one will be able to join in the future, including you."</string> + <string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string> + <string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string> + <string name="login_initial_device_name_android">"%1$s Android"</string> + <plurals name="common_member_count"> + <item quantity="one">"%1$d member"</item> + <item quantity="other">"%1$d members"</item> + </plurals> + <string name="preference_rageshake">"Rageshake to report bug"</string> + <string name="rageshake_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string> + <string name="report_content_explanation">"This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages."</string> + <string name="report_content_hint">"Reason for reporting this content"</string> + <string name="room_timeline_beginning_of_room">"This is the beginning of %1$s."</string> + <string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string> + <string name="room_timeline_read_marker_title">"New"</string> + <string name="screen_analytics_settings_share_data">"Share analytics data"</string> + <string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string> + <string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string> + <string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string> + <string name="screen_migration_message">"This is a one time process, thanks for waiting."</string> + <string name="screen_migration_title">"Setting up your account."</string> + <string name="screen_notification_settings_enable_notifications">"Enable notifications on this device"</string> + <string name="screen_notification_settings_system_notifications_action_required">"To receive notifications, please change your %1$s."</string> + <string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string> + <string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string> + <string name="screen_notification_settings_title">"Notifications"</string> + <string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string> + <string name="screen_share_location_title">"Share location"</string> + <string name="screen_share_my_location_action">"Share my location"</string> + <string name="screen_share_open_apple_maps">"Open in Apple Maps"</string> + <string name="screen_share_open_google_maps">"Open in Google Maps"</string> + <string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string> + <string name="screen_share_this_location_action">"Share this location"</string> + <string name="screen_view_location_title">"Location"</string> + <string name="settings_rageshake">"Rageshake"</string> + <string name="settings_rageshake_detection_threshold">"Detection threshold"</string> + <string name="settings_title_general">"General"</string> + <string name="settings_version_number">"Version: %1$s (%2$s)"</string> + <string name="test_language_identifier">"en"</string> + <string name="test_untranslated_default_language_identifier">"en"</string> + <string name="dialog_title_error">"Error"</string> + <string name="dialog_title_success">"Success"</string> + <string name="screen_analytics_settings_help_us_improve">"Share anonymous usage data to help us identify issues."</string> + <string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string> + <string name="screen_analytics_settings_read_terms_content_link">"here"</string> + <string name="screen_report_content_block_user">"Block user"</string> +</resources> diff --git a/libraries/usersearch/api/build.gradle.kts b/libraries/usersearch/api/build.gradle.kts new file mode 100644 index 0000000000..39b03bfe90 --- /dev/null +++ b/libraries/usersearch/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.usersearch.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt new file mode 100644 index 0000000000..b204af447a --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.api + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser + +interface UserListDataSource { + //TODO should probably have a flow + suspend fun search(query: String, count: Long): List<MatrixUser> + suspend fun getProfile(userId: UserId): MatrixUser? +} diff --git a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt new file mode 100644 index 0000000000..03e9952c92 --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.api + +import kotlinx.coroutines.flow.Flow + +interface UserRepository { + + suspend fun search(query: String): Flow<List<UserSearchResult>> +} diff --git a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserSearchResult.kt b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserSearchResult.kt new file mode 100644 index 0000000000..e67a7af46f --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserSearchResult.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.api + +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class UserSearchResult( + val matrixUser: MatrixUser, + val isUnresolved: Boolean = false, +) diff --git a/libraries/usersearch/impl/build.gradle.kts b/libraries/usersearch/impl/build.gradle.kts new file mode 100644 index 0000000000..226860e86f --- /dev/null +++ b/libraries/usersearch/impl/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.usersearch.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.di) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.matrix.api) + api(projects.libraries.usersearch.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.usersearch.test) +} diff --git a/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSource.kt b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSource.kt new file mode 100644 index 0000000000..18dbf97af5 --- /dev/null +++ b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserListDataSource +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class MatrixUserListDataSource @Inject constructor( + private val client: MatrixClient +) : UserListDataSource { + override suspend fun search(query: String, count: Long): List<MatrixUser> { + val res = client.searchUsers(query, count) + return res.getOrNull()?.results.orEmpty() + } + + override suspend fun getProfile(userId: UserId): MatrixUser? { + return client.getProfile(userId).getOrNull() + } +} diff --git a/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepository.kt b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepository.kt new file mode 100644 index 0000000000..0714528836 --- /dev/null +++ b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserListDataSource +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class MatrixUserRepository @Inject constructor( + private val client: MatrixClient, + private val dataSource: UserListDataSource +) : UserRepository { + + override suspend fun search(query: String): Flow<List<UserSearchResult>> = flow { + // If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results. + val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query)) + if (shouldQueryProfile) { + emit(listOf(UserSearchResult(MatrixUser(UserId(query))))) + } + + if (query.length >= MINIMUM_SEARCH_LENGTH) { + // Debounce + delay(DEBOUNCE_TIME_MILLIS) + + val results = dataSource + .search(query, MAXIMUM_SEARCH_RESULTS) + .filter { !client.isMe(it.userId) } + .map { UserSearchResult(it) } + .toMutableList() + + // If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly + if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) { + results.add( + 0, + dataSource.getProfile(UserId(query)) + ?.let { UserSearchResult(it) } + ?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true)) + } + + emit(results) + } + } + + companion object { + private const val DEBOUNCE_TIME_MILLIS = 250L + private const val MINIMUM_SEARCH_LENGTH = 3 + private const val MAXIMUM_SEARCH_RESULTS = 10L + } +} diff --git a/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSourceTest.kt b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSourceTest.kt new file mode 100644 index 0000000000..793a9f7f58 --- /dev/null +++ b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSourceTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.impl + +import com.google.common.truth.Truth +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class MatrixUserListDataSourceTest { + + @Test + fun `search - returns users on success`() = runTest { + val matrixClient = FakeMatrixClient() + matrixClient.givenSearchUsersResult( + searchTerm = "test", + result = Result.success( + MatrixSearchUserResults( + results = listOf( + aMatrixUserProfile(), + aMatrixUserProfile(userId = A_USER_ID_2) + ), + limited = false + ) + ) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val results = dataSource.search("test", 2) + Truth.assertThat(results).containsExactly( + aMatrixUserProfile(), + aMatrixUserProfile(userId = A_USER_ID_2) + ) + } + + @Test + fun `search - returns empty list on error`() = runTest { + val matrixClient = FakeMatrixClient() + matrixClient.givenSearchUsersResult( + searchTerm = "test", + result = Result.failure(Throwable("Ruhroh")) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val results = dataSource.search("test", 2) + Truth.assertThat(results).isEmpty() + } + + @Test + fun `get profile - returns user on success`() = runTest { + val matrixClient = FakeMatrixClient() + matrixClient.givenGetProfileResult( + userId = A_USER_ID, + result = Result.success(aMatrixUserProfile()) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val result = dataSource.getProfile(A_USER_ID) + Truth.assertThat(result).isEqualTo(aMatrixUserProfile()) + } + + @Test + fun `get profile - returns null on error`() = runTest { + val matrixClient = FakeMatrixClient() + matrixClient.givenGetProfileResult( + userId = A_USER_ID, + result = Result.failure(Throwable("Ruhroh")) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val result = dataSource.getProfile(A_USER_ID) + Truth.assertThat(result).isNull() + } + + private fun aMatrixUserProfile( + userId: UserId = A_USER_ID, + displayName: String = A_USER_NAME, + avatarUrl: String = AN_AVATAR_URL + ) = MatrixUser(userId, displayName, avatarUrl) +} diff --git a/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepositoryTest.kt b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepositoryTest.kt new file mode 100644 index 0000000000..621274bef5 --- /dev/null +++ b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepositoryTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.usersearch.api.UserSearchResult +import io.element.android.libraries.usersearch.test.FakeUserListDataSource +import kotlinx.coroutines.test.runTest +import org.junit.Test + +private val SESSION_ID = SessionId("@current-user:example.com") + +internal class MatrixUserRepositoryTest { + + @Test + fun `search - emits nothing if the search query is too short`() = runTest { + val dataSource = FakeUserListDataSource() + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search("x") + + result.test { + awaitComplete() + } + } + + @Test + fun `search - returns empty list if no results are found`() = runTest { + val dataSource = FakeUserListDataSource() + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search("some query") + + result.test { + assertThat(awaitItem()).isEmpty() + awaitComplete() + } + } + + @Test + fun `search - returns users if results are found`() = runTest { + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(aMatrixUserList()) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search("some query") + + result.test { + assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults()) + awaitComplete() + } + } + + @Test + fun `search - immediately returns placeholder if search is mxid`() = runTest { + val dataSource = FakeUserListDataSource() + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(A_USER_ID.value) + + result.test { + assertThat(awaitItem()).isEqualTo(listOf(placeholderResult())) + skipItems(1) + awaitComplete() + } + } + + @Test + fun `search - doesn't return placeholder if search is the local user's mxid`() = runTest { + val dataSource = FakeUserListDataSource() + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(SESSION_ID.value) + + result.test { + assertThat(awaitItem()).isEmpty() + awaitComplete() + } + } + + @Test + fun `search - filters out results with the local user's mxid`() = runTest { + val searchResults = aMatrixUserList() + MatrixUser(userId = SESSION_ID, displayName = A_USER_NAME) + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(searchResults) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search("some text") + + result.test { + assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults()) + awaitComplete() + } + } + + @Test + fun `search - does not change results if they contain searched mxid`() = runTest { + val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME) + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(searchResults) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(A_USER_ID.value) + + result.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults()) + awaitComplete() + } + } + + @Test + fun `search - gets profile results if searched mxid not in results`() = runTest { + val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME) + val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(searchResults) + dataSource.givenUserProfile(userProfile) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(A_USER_ID.value) + + result.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults()) + awaitComplete() + } + } + + @Test + fun `search - doesn't add profile results if searched mxid is local user and not in results`() = runTest { + val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME) + val searchResults = aMatrixUserListWithoutUserId(SESSION_ID) + + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(searchResults) + dataSource.givenUserProfile(userProfile) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(SESSION_ID.value) + + result.test { + assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults()) + awaitComplete() + } + } + + @Test + fun `search - returns unresolved user if profile can't be loaded`() = runTest { + val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + + val dataSource = FakeUserListDataSource() + dataSource.givenSearchResult(searchResults) + dataSource.givenUserProfile(null) + val repository = MatrixUserRepository(FakeMatrixClient(SESSION_ID), dataSource) + + val result = repository.search(A_USER_ID.value) + + result.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults()) + awaitComplete() + } + } + + private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId } + + private fun List<MatrixUser>.toUserSearchResults() = map { UserSearchResult(it) } + + private fun placeholderResult(id: UserId = A_USER_ID, isUnresolved: Boolean = false) = UserSearchResult(MatrixUser(id), isUnresolved = isUnresolved) + +} diff --git a/libraries/usersearch/test/build.gradle.kts b/libraries/usersearch/test/build.gradle.kts new file mode 100644 index 0000000000..dc8a0b3d69 --- /dev/null +++ b/libraries/usersearch/test/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.usersearch" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.matrix.api) + api(projects.libraries.usersearch.api) +} diff --git a/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserListDataSource.kt b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserListDataSource.kt new file mode 100644 index 0000000000..23935c1a6c --- /dev/null +++ b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserListDataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.test + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.usersearch.api.UserListDataSource + +class FakeUserListDataSource : UserListDataSource { + + private var searchResult: List<MatrixUser> = emptyList() + private var profile: MatrixUser? = null + + override suspend fun search(query: String, count: Long): List<MatrixUser> = searchResult.take(count.toInt()) + + override suspend fun getProfile(userId: UserId): MatrixUser? = profile + + fun givenSearchResult(users: List<MatrixUser>) { + this.searchResult = users + } + + fun givenUserProfile(matrixUser: MatrixUser?) { + this.profile = matrixUser + } +} diff --git a/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserRepository.kt b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserRepository.kt new file mode 100644 index 0000000000..0911d86b36 --- /dev/null +++ b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserRepository.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.libraries.usersearch.test + +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeUserRepository : UserRepository { + + var providedQuery: String? = null + private set + + private val flow = MutableSharedFlow<List<UserSearchResult>>() + + override suspend fun search(query: String): Flow<List<UserSearchResult>> { + providedQuery = query + return flow + } + + suspend fun emitResult(result: List<UserSearchResult>) { + flow.emit(result) + } + +} diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts new file mode 100644 index 0000000000..a64b822d62 --- /dev/null +++ b/plugins/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ +plugins { + `kotlin-dsl` + `kotlin-dsl-precompiled-script-plugins` +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation(libs.android.gradle.plugin) + implementation(libs.kotlin.gradle.plugin) + implementation(platform(libs.google.firebase.bom)) + // FIXME: using the bom ^, it should not be necessary to provide the version v... + implementation("com.google.firebase:firebase-appdistribution-gradle:4.0.0") + implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) +} diff --git a/plugins/settings.gradle.kts b/plugins/settings.gradle.kts new file mode 100644 index 0000000000..defcb6f17b --- /dev/null +++ b/plugins/settings.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +dependencyResolutionManagement { + repositories { + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt new file mode 100644 index 0000000000..36a3a33e70 --- /dev/null +++ b/plugins/src/main/kotlin/Versions.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +import org.gradle.api.JavaVersion +import org.gradle.jvm.toolchain.JavaLanguageVersion + +object Versions { + const val versionCode = 100100 + const val versionName = "0.1.0" + + const val compileSdk = 33 + const val targetSdk = 33 + const val minSdk = 23 + val javaCompileVersion = JavaVersion.VERSION_17 + val javaLanguageVersion: JavaLanguageVersion = JavaLanguageVersion.of(11) +} diff --git a/plugins/src/main/kotlin/extension/CommonExtension.kt b/plugins/src/main/kotlin/extension/CommonExtension.kt new file mode 100644 index 0000000000..000aa3264d --- /dev/null +++ b/plugins/src/main/kotlin/extension/CommonExtension.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 extension + +import Versions +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import java.io.File +import org.gradle.accessors.dm.LibrariesForLibs + +fun CommonExtension<*, *, *, *>.androidConfig(project: Project) { + defaultConfig { + compileSdk = Versions.compileSdk + minSdk = Versions.minSdk + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests.isReturnDefaultValues = true + } + + lint { + lintConfig = File("${project.rootDir}/tools/lint/lint.xml") + checkDependencies = true + abortOnError = true + ignoreTestFixturesSources = true + } +} + +fun CommonExtension<*, *, *, *>.composeConfig(libs: LibrariesForLibs) { + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composecompiler.get() + } + + packaging { + resources.excludes.apply { + add("META-INF/AL2.0") + add("META-INF/LGPL2.1") + } + } + + lint { + // Extra rules for compose + // Disabled until lint stops inspecting generated ksp files... + // error.add("ComposableLambdaParameterNaming") + error.add("ComposableLambdaParameterPosition") + ignoreTestFixturesSources = true + } +} + diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt new file mode 100644 index 0000000000..88f499b993 --- /dev/null +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 extension + +import org.gradle.accessors.dm.LibrariesForLibs +import org.gradle.kotlin.dsl.DependencyHandlerScope +import org.gradle.kotlin.dsl.project +import org.gradle.api.logging.Logger +import java.io.File + +private fun DependencyHandlerScope.implementation(dependency: Any) = dependencies.add("implementation", dependency) + +private fun DependencyHandlerScope.androidTestImplementation(dependency: Any) = dependencies.add("androidTestImplementation", dependency) + +private fun DependencyHandlerScope.debugImplementation(dependency: Any) = dependencies.add("debugImplementation", dependency) + +/** + * Dependencies used by all the modules + */ +fun DependencyHandlerScope.commonDependencies(libs: LibrariesForLibs) { + implementation(libs.timber) +} + +/** + * Dependencies used by all the modules with composable items + */ +fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material:material") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation(libs.androidx.activity.compose) + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation(libs.showkase) + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5") +} + +private fun DependencyHandlerScope.addImplementationProjects( + directory: File, + path: String, + nameFilter: String, + logger: Logger, +) { + directory.listFiles().orEmpty().also { it.sort() }.forEach { file -> + if (file.isDirectory) { + val newPath = "$path:${file.name}" + val buildFile = File(file, "build.gradle.kts") + if (buildFile.exists() && file.name == nameFilter) { + implementation(project(newPath)) + logger.lifecycle("Added implementation(project($newPath))") + } else { + addImplementationProjects(file, newPath, nameFilter, logger) + } + } + } +} + +fun DependencyHandlerScope.allLibrariesImpl() { + implementation(project(":libraries:androidutils")) + implementation(project(":libraries:deeplink")) + implementation(project(":libraries:designsystem")) + implementation(project(":libraries:matrix:impl")) + implementation(project(":libraries:matrixui")) + implementation(project(":libraries:network")) + implementation(project(":libraries:core")) + implementation(project(":libraries:eventformatter:impl")) + implementation(project(":libraries:permissions:impl")) + implementation(project(":libraries:push:impl")) + implementation(project(":libraries:push:impl")) + // Comment to not include firebase in the project + implementation(project(":libraries:pushproviders:firebase")) + // Comment to not include unified push in the project + implementation(project(":libraries:pushproviders:unifiedpush")) + implementation(project(":libraries:featureflag:impl")) + implementation(project(":libraries:pushstore:impl")) + implementation(project(":libraries:architecture")) + implementation(project(":libraries:dateformatter:impl")) + implementation(project(":libraries:di")) + implementation(project(":libraries:session-storage:impl")) + implementation(project(":libraries:mediapickers:impl")) + implementation(project(":libraries:mediaupload:impl")) + implementation(project(":libraries:usersearch:impl")) + implementation(project(":libraries:textcomposer")) +} + +fun DependencyHandlerScope.allServicesImpl() { + implementation(project(":services:analytics:impl")) + implementation(project(":services:analyticsproviders:posthog")) + implementation(project(":services:apperror:impl")) + implementation(project(":services:appnavstate:impl")) + implementation(project(":services:toolbox:impl")) +} + +fun DependencyHandlerScope.allFeaturesApi(rootDir: File, logger: Logger) { + val featuresDir = File(rootDir, "features") + addImplementationProjects(featuresDir, ":features", "api", logger) +} + +fun DependencyHandlerScope.allFeaturesImpl(rootDir: File, logger: Logger) { + val featuresDir = File(rootDir, "features") + addImplementationProjects(featuresDir, ":features", "impl", logger) +} diff --git a/plugins/src/main/kotlin/extension/VersionCatalog.kt b/plugins/src/main/kotlin/extension/VersionCatalog.kt new file mode 100644 index 0000000000..83eef66192 --- /dev/null +++ b/plugins/src/main/kotlin/extension/VersionCatalog.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 extension + +import org.gradle.api.artifacts.VersionCatalog + +private fun VersionCatalog.getVersion(alias: String) = findVersion(alias).get() + +private fun VersionCatalog.getLibrary(library: String) = findLibrary(library).get() + +private fun VersionCatalog.getBundle(bundle: String) = findBundle(bundle).get() + +private fun VersionCatalog.getPlugin(plugin: String) = findPlugin(plugin).get() diff --git a/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts b/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts new file mode 100644 index 0000000000..af73409888 --- /dev/null +++ b/plugins/src/main/kotlin/io.element.android-compose-application.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +/** + * This will generate the plugin "io.element.android-compose-application" to use by app and samples modules + */ +import extension.androidConfig +import extension.commonDependencies +import extension.composeConfig +import extension.composeDependencies +import org.gradle.accessors.dm.LibrariesForLibs + +val libs = the<LibrariesForLibs>() +plugins { + id("com.android.application") + id("kotlin-android") +} + +android { + androidConfig(project) + composeConfig(libs) +} + +dependencies { + commonDependencies(libs) + composeDependencies(libs) +} diff --git a/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts b/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts new file mode 100644 index 0000000000..e420ab3c8d --- /dev/null +++ b/plugins/src/main/kotlin/io.element.android-compose-library.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +/** + * This will generate the plugin "io.element.android-compose-library", used in android library with compose modules. + */ +import extension.androidConfig +import extension.commonDependencies +import extension.composeConfig +import extension.composeDependencies +import org.gradle.accessors.dm.LibrariesForLibs + +val libs = the<LibrariesForLibs>() +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + androidConfig(project) + composeConfig(libs) +} + +dependencies { + commonDependencies(libs) + composeDependencies(libs) +} diff --git a/plugins/src/main/kotlin/io.element.android-library.gradle.kts b/plugins/src/main/kotlin/io.element.android-library.gradle.kts new file mode 100644 index 0000000000..6c3c77223c --- /dev/null +++ b/plugins/src/main/kotlin/io.element.android-library.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +/** + * This will generate the plugin "io.element.android-library", used in android library without compose modules. + */ +import extension.androidConfig +import extension.commonDependencies +import org.gradle.accessors.dm.LibrariesForLibs + +val libs = the<LibrariesForLibs>() +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + androidConfig(project) +} + +dependencies { + commonDependencies(libs) +} diff --git a/samples/minimal/.gitignore b/samples/minimal/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/samples/minimal/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts new file mode 100644 index 0000000000..8063ac9b0a --- /dev/null +++ b/samples/minimal/build.gradle.kts @@ -0,0 +1,68 @@ + +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-compose-application") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "io.element.android.samples.minimal" + + defaultConfig { + applicationId = "io.element.android.samples.minimal" + targetSdk = Versions.targetSdk + versionCode = Versions.versionCode + versionName = Versions.versionName + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} + +dependencies { + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.preference) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.impl) + implementation(projects.libraries.permissions.noop) + implementation(projects.libraries.sessionStorage.implMemory) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.network) + implementation(projects.libraries.dateformatter.impl) + implementation(projects.libraries.eventformatter.impl) + implementation(projects.features.invitelist.impl) + implementation(projects.features.roomlist.impl) + implementation(projects.features.leaveroom.impl) + implementation(projects.features.login.impl) + implementation(projects.features.networkmonitor.impl) + implementation(projects.services.toolbox.impl) + implementation(libs.coroutines.core) + coreLibraryDesugaring(libs.android.desugar) +} diff --git a/samples/minimal/src/main/AndroidManifest.xml b/samples/minimal/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..aec1ea168c --- /dev/null +++ b/samples/minimal/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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"> + + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.ElementX"> + <activity + android:name=".MainActivity" + android:exported="true" + android:theme="@style/Theme.ElementX"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt new file mode 100644 index 0000000000..ec4d7cf9f2 --- /dev/null +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/LoginScreen.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.samples.minimal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordPresenter +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordView +import io.element.android.features.login.impl.util.defaultAccountProvider +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService + +class LoginScreen(private val authenticationService: MatrixAuthenticationService) { + + @Composable + fun Content(modifier: Modifier = Modifier) { + val presenter = remember { + LoginPasswordPresenter( + authenticationService = authenticationService, + AccountProviderDataSource(), + DefaultLoginUserStory(), + ) + } + + LaunchedEffect(Unit) { + authenticationService.setHomeserver(defaultAccountProvider.title) + } + + val state = presenter.present() + LoginPasswordView( + state = state, + modifier = modifier, + onBackPressed = {}, + onWaitListError = {}, + ) + } +} diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt new file mode 100644 index 0000000000..21d6648a41 --- /dev/null +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.samples.minimal + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.view.WindowCompat +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService +import io.element.android.libraries.network.useragent.SimpleUserAgentProvider +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock +import kotlinx.coroutines.runBlocking +import java.io.File + +class MainActivity : ComponentActivity() { + + private val matrixAuthenticationService: MatrixAuthenticationService by lazy { + val baseDirectory = File(applicationContext.filesDir, "sessions") + + RustMatrixAuthenticationService( + context = applicationContext, + baseDirectory = baseDirectory, + appCoroutineScope = Singleton.appScope, + coroutineDispatchers = Singleton.coroutineDispatchers, + sessionStore = InMemorySessionStore(), + clock = DefaultSystemClock(), + userAgentProvider = SimpleUserAgentProvider("MinimalSample") + ) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { + ElementTheme { + val isLoggedIn by matrixAuthenticationService.isLoggedIn().collectAsState(initial = false) + Content(isLoggedIn = isLoggedIn, modifier = Modifier.fillMaxSize()) + } + + } + } + + @Composable + fun Content( + isLoggedIn: Boolean, + modifier: Modifier = Modifier + ) { + if (!isLoggedIn) { + LoginScreen(authenticationService = matrixAuthenticationService).Content(modifier) + } else { + val matrixClient = runBlocking { + val sessionId = matrixAuthenticationService.getLatestSessionId()!! + matrixAuthenticationService.restoreSession(sessionId).getOrNull() + } + RoomListScreen(LocalContext.current, matrixClient!!).Content(modifier) + } + } +} diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt new file mode 100644 index 0000000000..f66de878fb --- /dev/null +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.samples.minimal + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore +import io.element.android.features.leaveroom.impl.LeaveRoomPresenterImpl +import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl +import io.element.android.features.roomlist.impl.RoomListPresenter +import io.element.android.features.roomlist.impl.RoomListView +import io.element.android.features.roomlist.impl.datasource.DefaultInviteStateDataSource +import io.element.android.features.roomlist.impl.datasource.RoomListDataSource +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.impl.DateFormatters +import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter +import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider +import io.element.android.libraries.designsystem.utils.SnackbarDispatcher +import io.element.android.libraries.eventformatter.impl.DefaultRoomLastMessageFormatter +import io.element.android.libraries.eventformatter.impl.ProfileChangeContentFormatter +import io.element.android.libraries.eventformatter.impl.RoomMembershipContentFormatter +import io.element.android.libraries.eventformatter.impl.StateContentFormatter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import timber.log.Timber +import java.util.Locale + +class RoomListScreen( + context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers = Singleton.coroutineDispatchers, +) { + private val clock = Clock.System + private val locale = Locale.getDefault() + private val timeZone = TimeZone.currentSystemDefault() + private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) + private val dateFormatters = DateFormatters(locale, clock, timeZone) + private val sessionVerificationService = matrixClient.sessionVerificationService() + private val stringProvider = AndroidStringProvider(context.resources) + private val presenter = RoomListPresenter( + client = matrixClient, + sessionVerificationService = sessionVerificationService, + networkMonitor = NetworkMonitorImpl(context, Singleton.appScope), + snackbarDispatcher = SnackbarDispatcher(), + inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), + leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers), + roomListDataSource = RoomListDataSource( + roomSummaryDataSource = matrixClient.roomSummaryDataSource, + lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), + roomLastMessageFormatter = DefaultRoomLastMessageFormatter( + sp = stringProvider, + matrixClient = matrixClient, + roomMembershipContentFormatter = RoomMembershipContentFormatter(matrixClient, stringProvider), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider), + ), + coroutineDispatchers = coroutineDispatchers, + ) + ) + + @Composable + fun Content(modifier: Modifier = Modifier) { + fun onRoomClicked(roomId: RoomId) { + Singleton.appScope.launch { + withContext(coroutineDispatchers.io) { + matrixClient.getRoom(roomId)!!.use { room -> + room.open() + room.timeline.paginateBackwards(20, 50) + } + } + } + } + + val state = presenter.present() + RoomListView( + state = state, + onRoomClicked = ::onRoomClicked, + onSettingsClicked = {}, + onVerifyClicked = {}, + onCreateRoomClicked = {}, + onInvitesClicked = {}, + onRoomSettingsClicked = {}, + onMenuActionClicked = {}, + modifier = modifier, + ) + + DisposableEffect(Unit) { + Timber.w("Start sync!") + runBlocking { + matrixClient.syncService().startSync() + } + onDispose { + Timber.w("Stop sync!") + matrixClient.syncService().stopSync() + } + } + } +} diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt new file mode 100644 index 0000000000..5f8c6555a5 --- /dev/null +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.samples.minimal + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.impl.tracing.setupTracing +import io.element.android.libraries.matrix.api.tracing.TracingConfigurations +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.plus +import timber.log.Timber + +object Singleton { + + init { + Timber.plant(Timber.DebugTree()) + setupTracing(TracingConfigurations.debug) + } + + val appScope = MainScope() + CoroutineName("Minimal Scope") + val coroutineDispatchers = CoroutineDispatchers( + io = Dispatchers.IO, + computation = Dispatchers.Default, + main = Dispatchers.Main, + ) +} diff --git a/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher.webp b/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher.webp b/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher.webp b/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/samples/minimal/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/samples/minimal/src/main/res/values-night/themes.xml b/samples/minimal/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..b059f36a0e --- /dev/null +++ b/samples/minimal/src/main/res/values-night/themes.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + <style name="Theme.ElementX" parent="android:Theme.Material.NoActionBar" /> +</resources> diff --git a/samples/minimal/src/main/res/values/strings.xml b/samples/minimal/src/main/res/values/strings.xml new file mode 100644 index 0000000000..5b70784037 --- /dev/null +++ b/samples/minimal/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + <string name="app_name">EAX-Sample</string> +</resources> diff --git a/samples/minimal/src/main/res/values/themes.xml b/samples/minimal/src/main/res/values/themes.xml new file mode 100644 index 0000000000..39a7c9c0e6 --- /dev/null +++ b/samples/minimal/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2023 New Vector Ltd + ~ + ~ 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. + --> + +<resources> + <style name="Theme.ElementX" parent="android:Theme.Material.Light.NoActionBar" /> +</resources> diff --git a/services/analytics/api/build.gradle.kts b/services/analytics/api/build.gradle.kts new file mode 100644 index 0000000000..28b871a659 --- /dev/null +++ b/services/analytics/api/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.analytics.api" +} + +dependencies { + api(projects.services.analyticsproviders.api) + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.core) +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt new file mode 100644 index 0000000000..9c6fb2d522 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analytics.api + +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker +import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker +import kotlinx.coroutines.flow.Flow + +interface AnalyticsService: AnalyticsTracker, ErrorTracker { + fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> + + /** + * Return a Flow of Boolean, true if the user has given their consent. + */ + fun getUserConsent(): Flow<Boolean> + + /** + * Update the user consent value. + */ + suspend fun setUserConsent(userConsent: Boolean) + + /** + * Return a Flow of Boolean, true if the user has been asked for their consent. + */ + fun didAskUserConsent(): Flow<Boolean> + + /** + * Store the fact that the user has been asked for their consent. + */ + suspend fun setDidAskUserConsent() + + /** + * Return a Flow of String, used for analytics Id. + */ + fun getAnalyticsId(): Flow<String> + + /** + * Update analyticsId from the AccountData. + */ + suspend fun setAnalyticsId(analyticsId: String) + + /** + * To be called when a session is destroyed. + */ + suspend fun onSignOut() + + /** + * Reset the analytics service (will ask for user consent again). + */ + suspend fun reset() +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt new file mode 100644 index 0000000000..ec67b3acf2 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.services.analytics.api.extensions + +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.room.MatrixRoom + +fun Long?.toAnalyticsRoomSize(): JoinedRoom.RoomSize { + return when (this) { + null, + 2L -> JoinedRoom.RoomSize.Two + in 3..10 -> JoinedRoom.RoomSize.ThreeToTen + in 11..100 -> JoinedRoom.RoomSize.ElevenToOneHundred + in 101..1000 -> JoinedRoom.RoomSize.OneHundredAndOneToAThousand + else -> JoinedRoom.RoomSize.MoreThanAThousand + } +} + +fun MatrixRoom.toAnalyticsJoinedRoom(trigger: JoinedRoom.Trigger?): JoinedRoom { + return JoinedRoom( + isDM = this.isDirect.orFalse(), + isSpace = MatrixPatterns.isSpaceId(this.roomId.value), + roomSize = this.joinedMemberCount.toAnalyticsRoomSize(), + trigger = trigger + ) +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt new file mode 100644 index 0000000000..0890ca58dc --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.services.analytics.api.extensions + +import im.vector.app.features.analytics.plan.ViewRoom +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.room.MatrixRoom + +fun MatrixRoom.toAnalyticsViewRoom(trigger: ViewRoom.Trigger? = null, selectedSpace: MatrixRoom? = null, viaKeyboard: Boolean? = null): ViewRoom { + val activeSpace = selectedSpace?.toActiveSpace() ?: ViewRoom.ActiveSpace.Home + + return ViewRoom( + isDM = this.isDirect.orFalse(), + isSpace = MatrixPatterns.isSpaceId(this.roomId.value), + trigger = trigger, + activeSpace = activeSpace, + viaKeyboard = viaKeyboard + ) +} + +private fun MatrixRoom.toActiveSpace(): ViewRoom.ActiveSpace { + return if (isPublic) ViewRoom.ActiveSpace.Public else ViewRoom.ActiveSpace.Private +} diff --git a/services/analytics/impl/build.gradle.kts b/services/analytics/impl/build.gradle.kts new file mode 100644 index 0000000000..5dd72d77bd --- /dev/null +++ b/services/analytics/impl/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.services.analytics.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.sessionStorage.api) + + api(projects.services.analyticsproviders.api) + api(projects.services.analytics.api) + implementation(libs.androidx.datastore.preferences) + + testImplementation(libs.coroutines.test) + testImplementation(libs.test.mockk) +} diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt new file mode 100644 index 0000000000..5639f954ac --- /dev/null +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analytics.impl + +import com.squareup.anvil.annotations.ContributesBinding +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.impl.log.analyticsTag +import io.element.android.services.analytics.impl.store.AnalyticsStore +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = AnalyticsService::class) +class DefaultAnalyticsService @Inject constructor( + private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>, + private val analyticsStore: AnalyticsStore, +// private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, + private val coroutineScope: CoroutineScope, + private val sessionObserver: SessionObserver, +) : AnalyticsService, SessionListener { + // Cache for the store values + private val userConsent = AtomicBoolean(false) + + // Cache for the properties to send + private var pendingUserProperties: UserProperties? = null + + init { + observeUserConsent() + observeSessions() + } + + override fun getAvailableAnalyticsProviders(): List<AnalyticsProvider> { + return analyticsProviders.sortedBy { it.index } + } + + override fun getUserConsent(): Flow<Boolean> { + return analyticsStore.userConsentFlow + } + + override suspend fun setUserConsent(userConsent: Boolean) { + Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)") + analyticsStore.setUserConsent(userConsent) + } + + override fun didAskUserConsent(): Flow<Boolean> { + return analyticsStore.didAskUserConsentFlow + } + + override suspend fun setDidAskUserConsent() { + Timber.tag(analyticsTag.value).d("setDidAskUserConsent()") + analyticsStore.setDidAskUserConsent() + } + + override suspend fun reset() { + analyticsStore.setDidAskUserConsent(false) + } + + override fun getAnalyticsId(): Flow<String> { + return analyticsStore.analyticsIdFlow + } + + override suspend fun setAnalyticsId(analyticsId: String) { + Timber.tag(analyticsTag.value).d("setAnalyticsId($analyticsId)") + analyticsStore.setAnalyticsId(analyticsId) + } + + override suspend fun onSignOut() { + // stop all providers + analyticsProviders.onEach { it.stop() } + } + + override suspend fun onSessionCreated(userId: String) { + // Nothing to do + } + + override suspend fun onSessionDeleted(userId: String) { + // Delete the store + analyticsStore.reset() + } + + private fun observeUserConsent() { + getUserConsent() + .onEach { consent -> + Timber.tag(analyticsTag.value).d("User consent updated to $consent") + userConsent.set(consent) + initOrStop() + } + .launchIn(coroutineScope) + } + + private fun observeSessions() { + sessionObserver.addListener(this) + } + + private fun initOrStop() { + if (userConsent.get()) { + analyticsProviders.onEach { it.init() } + pendingUserProperties?.let { + analyticsProviders.onEach { provider -> provider.updateUserProperties(it) } + pendingUserProperties = null + } + } else { + analyticsProviders.onEach { it.stop() } + } + } + + override fun capture(event: VectorAnalyticsEvent) { + Timber.tag(analyticsTag.value).d("capture($event)") + if (userConsent.get()) { + analyticsProviders.onEach { it.capture(event) } + } + } + + override fun screen(screen: VectorAnalyticsScreen) { + Timber.tag(analyticsTag.value).d("screen($screen)") + if (userConsent.get()) { + analyticsProviders.onEach { it.screen(screen) } + } + } + + override fun updateUserProperties(userProperties: UserProperties) { + if (userConsent.get()) { + analyticsProviders.onEach { it.updateUserProperties(userProperties) } + } else { + pendingUserProperties = userProperties + } + } + + override fun trackError(throwable: Throwable) { + if (userConsent.get()) { + analyticsProviders.onEach { it.trackError(throwable) } + } + } +} diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/log/AnalyticsLoggerTag.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/log/AnalyticsLoggerTag.kt new file mode 100644 index 0000000000..f323ac0a79 --- /dev/null +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/log/AnalyticsLoggerTag.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.services.analytics.impl.log + +import io.element.android.libraries.core.log.logger.LoggerTag + +val analyticsTag = LoggerTag("Analytics") diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/store/AnalyticsStore.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/store/AnalyticsStore.kt new file mode 100644 index 0000000000..476fd2a38a --- /dev/null +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/store/AnalyticsStore.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.services.analytics.impl.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.core.bool.orFalse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Also accessed via reflection by the instrumentation tests @see [im.vector.app.ClearCurrentSessionRule]. + */ +private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_analytics") + +/** + * Local storage for: + * - user consent (Boolean); + * - did ask user consent (Boolean); + * - analytics Id (String). + */ +class AnalyticsStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private val userConsent = booleanPreferencesKey("user_consent") + private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent") + private val analyticsId = stringPreferencesKey("analytics_id") + + val userConsentFlow: Flow<Boolean> = context.dataStore.data + .map { preferences -> preferences[userConsent].orFalse() } + .distinctUntilChanged() + + val didAskUserConsentFlow: Flow<Boolean> = context.dataStore.data + .map { preferences -> preferences[didAskUserConsent].orFalse() } + .distinctUntilChanged() + + val analyticsIdFlow: Flow<String> = context.dataStore.data + .map { preferences -> preferences[analyticsId].orEmpty() } + .distinctUntilChanged() + + suspend fun setUserConsent(newUserConsent: Boolean) { + context.dataStore.edit { settings -> + settings[userConsent] = newUserConsent + } + } + + suspend fun setDidAskUserConsent(newValue: Boolean = true) { + context.dataStore.edit { settings -> + settings[didAskUserConsent] = newValue + } + } + + suspend fun setAnalyticsId(newAnalyticsId: String) { + context.dataStore.edit { settings -> + settings[analyticsId] = newAnalyticsId + } + } + + suspend fun reset() { + context.dataStore.edit { + it.clear() + } + } +} diff --git a/services/analytics/noop/build.gradle.kts b/services/analytics/noop/build.gradle.kts new file mode 100644 index 0000000000..a5678f5cb3 --- /dev/null +++ b/services/analytics/noop/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.services.analytics.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.di) + api(projects.services.analytics.api) +} diff --git a/services/analyticsproviders/api/build.gradle.kts b/services/analyticsproviders/api/build.gradle.kts new file mode 100644 index 0000000000..40657e9a77 --- /dev/null +++ b/services/analyticsproviders/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.analyticsproviders.api" +} + +dependencies { + api(libs.matrix.analytics.events) +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt new file mode 100644 index 0000000000..548f47d7ad --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.api + +import io.element.android.services.analyticsproviders.api.trackers.AnalyticsTracker +import io.element.android.services.analyticsproviders.api.trackers.ErrorTracker + +interface AnalyticsProvider: AnalyticsTracker, ErrorTracker { + /** + * Allow to sort providers, from lower index to higher index. + */ + val index: Int + + /** + * User friendly name. + */ + val name: String + + fun init() + + fun stop() +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt new file mode 100644 index 0000000000..e37053fbf7 --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.api.trackers + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties + +interface AnalyticsTracker { + /** + * Capture an Event. + */ + fun capture(event: VectorAnalyticsEvent) + + /** + * Track a displayed screen. + */ + fun screen(screen: VectorAnalyticsScreen) + + /** + * Update user specific properties. + */ + fun updateUserProperties(userProperties: UserProperties) +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/ErrorTracker.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/ErrorTracker.kt new file mode 100644 index 0000000000..fb1ffe79a8 --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/ErrorTracker.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.api.trackers + +interface ErrorTracker { + fun trackError(throwable: Throwable) +} diff --git a/services/analyticsproviders/posthog/build.gradle.kts b/services/analyticsproviders/posthog/build.gradle.kts new file mode 100644 index 0000000000..c9049af237 --- /dev/null +++ b/services/analyticsproviders/posthog/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.services.analyticsproviders.posthog" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(libs.posthog) { + exclude("com.android.support", "support-annotations") + } + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.services.analyticsproviders.api) +} diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PostHogFactory.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PostHogFactory.kt new file mode 100644 index 0000000000..b4fd6dcd18 --- /dev/null +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PostHogFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.posthog + +import android.content.Context +import com.posthog.android.PostHog +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +class PostHogFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val buildMeta: BuildMeta, +) { + + fun createPosthog(): PostHog { + return PostHog.Builder(context, PosthogConfig.postHogApiKey, PosthogConfig.postHogHost) + // Record certain application events automatically! (off/false by default) + // .captureApplicationLifecycleEvents() + // Record screen views automatically! (off/false by default) + // .recordScreenViews() + // Capture deep links as part of the screen call. (off by default) + // .captureDeepLinks() + // Maximum number of events to keep in queue before flushing (default 20) + // .flushQueueSize(20) + // Max delay before flushing the queue (30 seconds) + // .flushInterval(30, TimeUnit.SECONDS) + // Enable or disable collection of ANDROID_ID (true) + .collectDeviceId(false) + .logLevel(getLogLevel()) + .build() + } + + private fun getLogLevel(): PostHog.LogLevel { + return if (buildMeta.isDebuggable) { + PostHog.LogLevel.DEBUG + } else { + PostHog.LogLevel.INFO + } + } +} diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt new file mode 100644 index 0000000000..92e73195c0 --- /dev/null +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.posthog + +import com.posthog.android.Options +import com.posthog.android.PostHog +import com.posthog.android.Properties +import com.squareup.anvil.annotations.ContributesMultibinding +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.di.AppScope +import io.element.android.services.analyticsproviders.api.AnalyticsProvider +import io.element.android.services.analyticsproviders.posthog.log.analyticsTag +import timber.log.Timber +import javax.inject.Inject + +private val REUSE_EXISTING_ID: String? = null +private val IGNORED_OPTIONS: Options? = null + +@ContributesMultibinding(AppScope::class) +class PosthogAnalyticsProvider @Inject constructor( + private val postHogFactory: PostHogFactory, +) : AnalyticsProvider { + override val index = PosthogConfig.index + override val name = PosthogConfig.name + + private var posthog: PostHog? = null + private var analyticsId: String? = null + + override fun init() { + posthog = createPosthog() + posthog?.optOut(false) + identifyPostHog() + } + + override fun stop() { + // When opting out, ensure that the queue is flushed first, or it will be flushed later (after user has revoked consent) + posthog?.flush() + posthog?.optOut(true) + posthog?.shutdown() + posthog = null + analyticsId = null + } + + override fun capture(event: VectorAnalyticsEvent) { + posthog?.capture(event.getName(), event.getProperties()?.toPostHogProperties()) + } + + override fun screen(screen: VectorAnalyticsScreen) { + posthog?.screen(screen.getName(), screen.getProperties()?.toPostHogProperties()) + } + + override fun updateUserProperties(userProperties: UserProperties) { +// posthog?.identify( +// REUSE_EXISTING_ID, userProperties.getProperties()?.toPostHogUserProperties(), +// IGNORED_OPTIONS +// ) + } + + override fun trackError(throwable: Throwable) { + TODO("Not yet implemented") + } + + private fun createPosthog(): PostHog = postHogFactory.createPosthog() + + private fun identifyPostHog() { + val id = analyticsId ?: return + if (id.isEmpty()) { + Timber.tag(analyticsTag.value).d("reset") + posthog?.reset() + } else { + Timber.tag(analyticsTag.value).d("identify") +// posthog?.identify(id, lateInitUserPropertiesFactory.createUserProperties()?.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS) + } + } + + private fun Map<String, Any?>?.toPostHogProperties(): Properties? { + if (this == null) return null + + return Properties().apply { + putAll(this@toPostHogProperties) + } + } + + /** + * We avoid sending nulls as part of the UserProperties as this will reset the values across all devices. + * The UserProperties event has nullable properties to allow for clients to opt in. + */ + private fun Map<String, Any?>.toPostHogUserProperties(): Properties { + return Properties().apply { + putAll(this@toPostHogUserProperties.filter { it.value != null }) + } + } +} diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogConfig.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogConfig.kt new file mode 100644 index 0000000000..877fb7dc9a --- /dev/null +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogConfig.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.posthog + +object PosthogConfig { + const val index = 0 + const val name = "Posthog" + const val postHogHost = "https://posthog.element.dev" + const val postHogApiKey = "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN" +} diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/extensions/InteractionExt.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/extensions/InteractionExt.kt new file mode 100644 index 0000000000..2095c2e1d4 --- /dev/null +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/extensions/InteractionExt.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.posthog.extensions + +import im.vector.app.features.analytics.plan.Interaction + +fun Interaction.Name.toAnalyticsInteraction(interactionType: Interaction.InteractionType = Interaction.InteractionType.Touch) = + Interaction( + name = this, + interactionType = interactionType + ) diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/log/AnalyticsLoggerTag.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/log/AnalyticsLoggerTag.kt new file mode 100644 index 0000000000..8e64ca100d --- /dev/null +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/log/AnalyticsLoggerTag.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.services.analyticsproviders.posthog.log + +import io.element.android.libraries.core.log.logger.LoggerTag + +val analyticsTag = LoggerTag("Analytics") diff --git a/services/apperror/api/build.gradle.kts b/services/apperror/api/build.gradle.kts new file mode 100644 index 0000000000..94970d9774 --- /dev/null +++ b/services/apperror/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.apperror.api" +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorState.kt b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorState.kt new file mode 100644 index 0000000000..c808ebe503 --- /dev/null +++ b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.api + +sealed interface AppErrorState { + + object NoError : AppErrorState + + data class Error( + val title: String, + val body: String, + val dismiss: () -> Unit, + ) : AppErrorState + +} diff --git a/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateProvider.kt b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateProvider.kt new file mode 100644 index 0000000000..50d857645e --- /dev/null +++ b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.api + +fun aAppErrorState() = AppErrorState.Error( + title = "An error occurred", + body = "Something went wrong, and the details of that would go here.", + dismiss = {}, +) diff --git a/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateService.kt b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateService.kt new file mode 100644 index 0000000000..b1f9b97ac2 --- /dev/null +++ b/services/apperror/api/src/main/kotlin/io/element/android/services/apperror/api/AppErrorStateService.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.api + +import kotlinx.coroutines.flow.StateFlow + +interface AppErrorStateService { + + val appErrorStateFlow: StateFlow<AppErrorState> + + fun showError(title: String, body: String) + +} diff --git a/services/apperror/impl/build.gradle.kts b/services/apperror/impl/build.gradle.kts new file mode 100644 index 0000000000..285577de9a --- /dev/null +++ b/services/apperror/impl/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.services.apperror.impl" +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.anvilannotations) + + implementation(libs.coroutines.core) + implementation(libs.androidx.corektx) + + api(projects.services.apperror.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) + + ksp(libs.showkase.processor) +} diff --git a/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/AppErrorView.kt b/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/AppErrorView.kt new file mode 100644 index 0000000000..ac5d4ee75d --- /dev/null +++ b/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/AppErrorView.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.services.apperror.api.AppErrorState +import io.element.android.services.apperror.api.aAppErrorState + +@Composable +fun AppErrorView( + state: AppErrorState, +) { + if (state is AppErrorState.Error) { + AppErrorViewContent( + title = state.title, + body = state.body, + onDismiss = state.dismiss, + ) + } +} + +@Composable +fun AppErrorViewContent( + title: String, + body: String, + onDismiss: () -> Unit = { }, +) { + ErrorDialog( + title = title, + content = body, + onDismiss = onDismiss, + ) +} + +@Preview +@Composable +internal fun AppErrorViewLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun AppErrorViewDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AppErrorView( + state = aAppErrorState() + ) +} diff --git a/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateService.kt b/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateService.kt new file mode 100644 index 0000000000..813c00cd65 --- /dev/null +++ b/services/apperror/impl/src/main/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateService.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.services.apperror.api.AppErrorState +import io.element.android.services.apperror.api.AppErrorStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService { + + private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError) + override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState + + override fun showError(title: String, body: String) { + currentAppErrorState.value = AppErrorState.Error( + title = title, + body = body, + dismiss = { + currentAppErrorState.value = AppErrorState.NoError + }, + ) + } +} diff --git a/services/apperror/impl/src/test/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateServiceTest.kt b/services/apperror/impl/src/test/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateServiceTest.kt new file mode 100644 index 0000000000..71c20aa8a2 --- /dev/null +++ b/services/apperror/impl/src/test/kotlin/io/element/android/services/apperror/impl/DefaultAppErrorStateServiceTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.apperror.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.services.apperror.api.AppErrorState +import kotlinx.coroutines.test.runTest +import org.junit.Test + +internal class DefaultAppErrorStateServiceTest { + + @Test + fun `initial value is no error`() = runTest { + val service = DefaultAppErrorStateService() + + service.appErrorStateFlow.test { + val state = awaitItem() + assertThat(state).isInstanceOf(AppErrorState.NoError::class.java) + } + } + + @Test + fun `showError - emits value`() = runTest { + val service = DefaultAppErrorStateService() + + service.appErrorStateFlow.test { + skipItems(1) + + service.showError("Title", "Body") + val state = awaitItem() + assertThat(state).isInstanceOf(AppErrorState.Error::class.java) + + val errorState = state as AppErrorState.Error + assertThat(errorState.title).isEqualTo("Title") + assertThat(errorState.body).isEqualTo("Body") + } + } + + @Test + fun `dismiss - clears value`() = runTest { + val service = DefaultAppErrorStateService() + + service.appErrorStateFlow.test { + skipItems(1) + + service.showError("Title", "Body") + val state = awaitItem() + assertThat(state).isInstanceOf(AppErrorState.Error::class.java) + + val errorState = state as AppErrorState.Error + errorState.dismiss() + + assertThat(awaitItem()).isInstanceOf(AppErrorState.NoError::class.java) + } + } + +} diff --git a/services/appnavstate/api/build.gradle.kts b/services/appnavstate/api/build.gradle.kts new file mode 100644 index 0000000000..9ae81e15aa --- /dev/null +++ b/services/appnavstate/api/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.appnavstate.api" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.startup) + implementation(projects.libraries.matrix.api) +} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt new file mode 100644 index 0000000000..098769c370 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.api + +import kotlinx.coroutines.flow.StateFlow + +/** + * A service that tracks the foreground state of the app. + */ +interface AppForegroundStateService { + /** + * Any updates to the foreground state of the app will be emitted here. + */ + val isInForeground: StateFlow<Boolean> + + /** + * Start observing the foreground state. + */ + fun start() +} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt new file mode 100644 index 0000000000..0a6ab692d2 --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.api + +/** + * A wrapper for the current navigation state of the app, along with its foreground/background state. + */ +data class AppNavigationState( + val navigationState: NavigationState, + val isInForeground: Boolean, +) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt new file mode 100644 index 0000000000..50e6b3434e --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import kotlinx.coroutines.flow.StateFlow + +/** + * A service that tracks the navigation and foreground states of the app. + */ +interface AppNavigationStateService { + val appNavigationState: StateFlow<AppNavigationState> + + fun onNavigateToSession(owner: String, sessionId: SessionId) + fun onLeavingSession(owner: String) + + fun onNavigateToSpace(owner: String, spaceId: SpaceId) + fun onLeavingSpace(owner: String) + + fun onNavigateToRoom(owner: String, roomId: RoomId) + fun onLeavingRoom(owner: String) + + fun onNavigateToThread(owner: String, threadId: ThreadId) + fun onLeavingThread(owner: String) +} + diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt new file mode 100644 index 0000000000..12cd07f05e --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId + +/** + * Can represent the current global app navigation state. + * @param owner mostly a Node identifier associated with the state. + * We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate. + * Why this is needed : for now we rely on lifecycle methods of the node, which are async. + * If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node. + * So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it. + */ +sealed class NavigationState(open val owner: String) { + object Root : NavigationState("ROOT") + + data class Session( + override val owner: String, + val sessionId: SessionId, + ) : NavigationState(owner) + + data class Space( + override val owner: String, + // Can be fake value, if no space is selected + val spaceId: SpaceId, + val parentSession: Session, + ) : NavigationState(owner) + + data class Room( + override val owner: String, + val roomId: RoomId, + val parentSpace: Space, + ) : NavigationState(owner) + + data class Thread( + override val owner: String, + val threadId: ThreadId, + val parentRoom: Room, + ) : NavigationState(owner) +} diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt new file mode 100644 index 0000000000..b399934cac --- /dev/null +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.api + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId + +fun NavigationState.currentSessionId(): SessionId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> sessionId + is NavigationState.Space -> parentSession.sessionId + is NavigationState.Room -> parentSpace.parentSession.sessionId + is NavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId + } +} + +fun NavigationState.currentSpaceId(): SpaceId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> spaceId + is NavigationState.Room -> parentSpace.spaceId + is NavigationState.Thread -> parentRoom.parentSpace.spaceId + } +} + +fun NavigationState.currentRoomId(): RoomId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> null + is NavigationState.Room -> roomId + is NavigationState.Thread -> parentRoom.roomId + } +} + +fun NavigationState.currentThreadId(): ThreadId? { + return when (this) { + NavigationState.Root -> null + is NavigationState.Session -> null + is NavigationState.Space -> null + is NavigationState.Room -> null + is NavigationState.Thread -> threadId + } +} diff --git a/services/appnavstate/impl/build.gradle.kts b/services/appnavstate/impl/build.gradle.kts new file mode 100644 index 0000000000..4c6973b8da --- /dev/null +++ b/services/appnavstate/impl/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.services.appnavstate.impl" +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.anvilannotations) + + implementation(libs.coroutines.core) + implementation(libs.androidx.corektx) + implementation(libs.androidx.lifecycle.process) + + api(projects.services.appnavstate.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) + testImplementation(projects.services.appnavstate.test) +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt new file mode 100644 index 0000000000..27c3f12a6a --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class DefaultAppForegroundStateService : AppForegroundStateService { + + private val state = MutableStateFlow(false) + override val isInForeground: StateFlow<Boolean> = state + + private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle } + + override fun start() { + appLifecycle.addObserver(lifecycleObserver) + } + + private val lifecycleObserver = LifecycleEventObserver { _, _ -> state.value = getCurrentState() } + + private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt new file mode 100644 index 0000000000..9360ce93ec --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.AppNavigationState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("Navigation") + +/** + * TODO This will maybe not support properly navigation using permalink. + */ +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultAppNavigationStateService @Inject constructor( + private val appForegroundStateService: AppForegroundStateService, + private val coroutineScope: CoroutineScope, +) : AppNavigationStateService { + + private val state = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ) + override val appNavigationState: StateFlow<AppNavigationState> = state + + init { + coroutineScope.launch { + appForegroundStateService.start() + + appForegroundStateService.isInForeground.collect { isInForeground -> + state.getAndUpdate { it.copy(isInForeground = isInForeground) } + } + } + } + + override fun onNavigateToSession(owner: String, sessionId: SessionId) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue") + val newValue: NavigationState.Session = when (currentValue) { + is NavigationState.Session, + is NavigationState.Space, + is NavigationState.Room, + is NavigationState.Thread, + is NavigationState.Root -> NavigationState.Session(owner, sessionId) + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onNavigateToSpace(owner: String, spaceId: SpaceId) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue") + val newValue: NavigationState.Space = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue) + is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession) + is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession) + is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession) + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onNavigateToRoom(owner: String, roomId: RoomId) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue") + val newValue: NavigationState.Room = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue) + is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace) + is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace) + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onNavigateToThread(owner: String, threadId: ThreadId) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue") + val newValue: NavigationState.Thread = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue) + is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom) + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onLeavingThread(owner: String) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Leaving thread. Current state: $currentValue") + if (!currentValue.assertOwner(owner)) return + val newValue: NavigationState.Room = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> error("onNavigateToThread() must be called first") + is NavigationState.Thread -> currentValue.parentRoom + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onLeavingRoom(owner: String) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue") + if (!currentValue.assertOwner(owner)) return + val newValue: NavigationState.Space = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> error("onNavigateToRoom() must be called first") + is NavigationState.Room -> currentValue.parentSpace + is NavigationState.Thread -> currentValue.parentRoom.parentSpace + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onLeavingSpace(owner: String) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue") + if (!currentValue.assertOwner(owner)) return + val newValue: NavigationState.Session = when (currentValue) { + NavigationState.Root -> error("onNavigateToSession() must be called first") + is NavigationState.Session -> error("onNavigateToSpace() must be called first") + is NavigationState.Space -> currentValue.parentSession + is NavigationState.Room -> currentValue.parentSpace.parentSession + is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession + } + state.getAndUpdate { it.copy(navigationState = newValue) } + } + + override fun onLeavingSession(owner: String) { + val currentValue = state.value.navigationState + Timber.tag(loggerTag.value).d("Leaving session. Current state: $currentValue") + if (!currentValue.assertOwner(owner)) return + state.getAndUpdate { it.copy(navigationState = NavigationState.Root) } + } + + private fun NavigationState.assertOwner(owner: String): Boolean { + if (this.owner != owner) { + Timber.tag(loggerTag.value).d("Can't leave current state as the owner is not the same (current = ${this.owner}, new = $owner)") + return false + } + return true + } +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt new file mode 100644 index 0000000000..4537c9f902 --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl.di + +import android.content.Context +import androidx.startup.AppInitializer +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.impl.initializer.AppForegroundStateServiceInitializer + +@Module +@ContributesTo(AppScope::class) +object AppNavStateModule { + + @Provides + fun provideAppForegroundStateService( + @ApplicationContext context: Context + ): AppForegroundStateService = + AppInitializer.getInstance(context).initializeComponent(AppForegroundStateServiceInitializer::class.java) + +} diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt new file mode 100644 index 0000000000..cfd382a57b --- /dev/null +++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl.initializer + +import android.content.Context +import androidx.lifecycle.ProcessLifecycleInitializer +import androidx.startup.Initializer +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.impl.DefaultAppForegroundStateService + +class AppForegroundStateServiceInitializer : Initializer<AppForegroundStateService> { + override fun create(context: Context): AppForegroundStateService { + return DefaultAppForegroundStateService() + } + + override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf( + ProcessLifecycleInitializer::class.java + ) +} diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt new file mode 100644 index 0000000000..dd0e576c79 --- /dev/null +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.A_ROOM_OWNER +import io.element.android.services.appnavstate.test.A_SESSION_OWNER +import io.element.android.services.appnavstate.test.A_SPACE_OWNER +import io.element.android.services.appnavstate.test.A_THREAD_OWNER +import io.element.android.tests.testutils.runCancellableScopeTest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultNavigationStateServiceTest { + + @Test + fun testNavigation() = runCancellableScopeTest { scope -> + val service = createStateService(scope) + service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) + service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) + service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) + service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) + assertThat(service.appNavigationState.first().navigationState).isEqualTo( + NavigationState.Thread( + A_THREAD_OWNER, A_THREAD_ID, + NavigationState.Room( + A_ROOM_OWNER, + A_ROOM_ID, + NavigationState.Space( + A_SPACE_OWNER, + A_SPACE_ID, + NavigationState.Session( + A_SESSION_OWNER, + A_SESSION_ID + ) + ) + ) + ) + ) + } + + @Test + fun testFailure() = runCancellableScopeTest { scope -> + val service = createStateService(scope) + + assertThrows(IllegalStateException::class.java) { service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) } + } + + private fun createStateService( + coroutineScope: CoroutineScope + ) = DefaultAppNavigationStateService(FakeAppForegroundStateService(), coroutineScope) +} diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt new file mode 100644 index 0000000000..e243523bd0 --- /dev/null +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.impl + +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeAppForegroundStateService( + initialValue: Boolean = true, +) : AppForegroundStateService { + + private val state = MutableStateFlow(initialValue) + override val isInForeground: StateFlow<Boolean> = state + + override fun start() { + // No-op + } + + fun givenIsInForeground(isInForeground: Boolean) { + state.value = isInForeground + } +} diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts new file mode 100644 index 0000000000..656777dac1 --- /dev/null +++ b/services/appnavstate/test/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.appnavstate.test" +} + +dependencies { + api(projects.libraries.matrix.api) + api(projects.services.appnavstate.api) + implementation(libs.coroutines.core) + implementation(libs.androidx.lifecycle.runtime) +} diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt new file mode 100644 index 0000000000..63c3d4e967 --- /dev/null +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.test + +import io.element.android.libraries.matrix.api.core.MAIN_SPACE +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.NavigationState + +const val A_SESSION_OWNER = "aSessionOwner" +const val A_SPACE_OWNER = "aSpaceOwner" +const val A_ROOM_OWNER = "aRoomOwner" +const val A_THREAD_OWNER = "aThreadOwner" + +fun aNavigationState( + sessionId: SessionId? = null, + spaceId: SpaceId? = MAIN_SPACE, + roomId: RoomId? = null, + threadId: ThreadId? = null, +): NavigationState { + if (sessionId == null) { + return NavigationState.Root + } + val session = NavigationState.Session(A_SESSION_OWNER, sessionId) + if (spaceId == null) { + return session + } + val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session) + if (roomId == null) { + return space + } + val room = NavigationState.Room(A_ROOM_OWNER, roomId, space) + if (threadId == null) { + return room + } + return NavigationState.Thread(A_THREAD_OWNER, threadId, room) +} diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt new file mode 100644 index 0000000000..a09e2a9c5e --- /dev/null +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.appnavstate.test + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.AppNavigationState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeAppNavigationStateService( + private val fakeAppNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ), +) : AppNavigationStateService { + + override val appNavigationState: StateFlow<AppNavigationState> = fakeAppNavigationState + + override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit + override fun onLeavingSession(owner: String) = Unit + + override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit + + override fun onLeavingSpace(owner: String) = Unit + + override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit + + override fun onLeavingRoom(owner: String) = Unit + + override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit + + override fun onLeavingThread(owner: String) = Unit +} diff --git a/services/toolbox/api/build.gradle.kts b/services/toolbox/api/build.gradle.kts new file mode 100644 index 0000000000..799304a551 --- /dev/null +++ b/services/toolbox/api/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.services.toolbox.api" +} + +dependencies { + implementation(libs.androidx.corektx) +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt new file mode 100644 index 0000000000..414c9b632e --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/appname/AppNameProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.api.appname + +interface AppNameProvider { + fun getAppName(): String +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..38f2e2227c --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/sdk/BuildVersionSdkIntProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.api.sdk + +import androidx.annotation.ChecksSdkIntAtLeast + +interface BuildVersionSdkIntProvider { + /** + * Return the current version of the Android SDK. + */ + fun get(): Int + + /** + * Checks the if the current OS version is equal or greater than [version]. + * @return A `non-null` result if true, `null` otherwise. + */ + @ChecksSdkIntAtLeast(parameter = 0, lambda = 1) + fun <T> whenAtLeast(version: Int, result: () -> T): T? { + return if (get() >= version) { + result() + } else null + } + + @ChecksSdkIntAtLeast(parameter = 0) + fun isAtLeast(version: Int) = get() >= version +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/strings/StringProvider.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/strings/StringProvider.kt new file mode 100644 index 0000000000..4233ea3423 --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/strings/StringProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.api.strings + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes + +interface StringProvider { + /** + * Returns a localized string from the application's package's + * default string table. + * + * @param resId Resource id for the string + * @return The string data associated with the resource, stripped of styled + * text information. + */ + fun getString(@StringRes resId: Int): String + + /** + * Returns a localized formatted string from the application's package's + * default string table, substituting the format arguments as defined in + * [java.util.Formatter] and [java.lang.String.format]. + * + * @param resId Resource id for the format string + * @param formatArgs The format arguments that will be used for + * substitution. + * @return The string data associated with the resource, formatted and + * stripped of styled text information. + */ + fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String + fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String +} diff --git a/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/systemclock/SystemClock.kt b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/systemclock/SystemClock.kt new file mode 100644 index 0000000000..c9498a846f --- /dev/null +++ b/services/toolbox/api/src/main/kotlin/io/element/android/services/toolbox/api/systemclock/SystemClock.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.api.systemclock + +fun interface SystemClock { + fun epochMillis(): Long +} diff --git a/services/toolbox/impl/build.gradle.kts b/services/toolbox/impl/build.gradle.kts new file mode 100644 index 0000000000..c526473a95 --- /dev/null +++ b/services/toolbox/impl/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.services.toolbox.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.di) + api(projects.services.toolbox.api) + implementation(libs.androidx.corektx) +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt new file mode 100644 index 0000000000..7a5cbd46f0 --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/appname/DefaultAppNameProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.impl.appname + +import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.services.toolbox.api.appname.AppNameProvider +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAppNameProvider @Inject constructor(@ApplicationContext private val context: Context) : + AppNameProvider { + + override fun getAppName(): String { + return try { + val appPackageName = context.packageName + var appName = context.getApplicationLabel(appPackageName) + + // Use appPackageName instead of appName if appName contains any non-ASCII character + if (!appName.matches("\\A\\p{ASCII}*\\z".toRegex())) { + appName = appPackageName + } + appName + } catch (e: Exception) { + Timber.e(e, "## AppNameProvider() : failed") + "ElementAndroid" + } + } +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt new file mode 100644 index 0000000000..d4ac1ec739 --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/sdk/DefaultBuildVersionSdkIntProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.impl.sdk + +import android.os.Build +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultBuildVersionSdkIntProvider @Inject constructor() : + BuildVersionSdkIntProvider { + override fun get() = Build.VERSION.SDK_INT +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/strings/AndroidStringProvider.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/strings/AndroidStringProvider.kt new file mode 100644 index 0000000000..ee3931eabb --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/strings/AndroidStringProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.impl.strings + +import android.content.res.Resources +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class AndroidStringProvider @Inject constructor(private val resources: Resources) : StringProvider { + override fun getString(@StringRes resId: Int): String { + return resources.getString(resId) + } + + override fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String { + return resources.getString(resId, *formatArgs) + } + + override fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String { + return resources.getQuantityString(resId, quantity, *formatArgs) + } +} diff --git a/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/systemclock/DefaultSystemClock.kt b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/systemclock/DefaultSystemClock.kt new file mode 100644 index 0000000000..85479d44b0 --- /dev/null +++ b/services/toolbox/impl/src/main/kotlin/io/element/android/services/toolbox/impl/systemclock/DefaultSystemClock.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.services.toolbox.impl.systemclock + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.services.toolbox.api.systemclock.SystemClock +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultSystemClock @Inject constructor() : SystemClock { + + /** + * Provides a UTC epoch in milliseconds + * + * This value is not guaranteed to be correct with reality + * as a User can override the system time and date to any values. + */ + override fun epochMillis(): Long { + return System.currentTimeMillis() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000000..408c9e2934 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,74 @@ +import java.net.URI + +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +pluginManagement { + repositories { + includeBuild("plugins") + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { + url = URI("https://www.jitpack.io") + content { + includeModule("com.github.UnifiedPush", "android-connector") + includeModule("com.github.matrix-org", "matrix-analytics-events") + } + } + flatDir { + dirs("libraries/matrix/libs") + } + } +} + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +rootProject.name = "ElementX" +include(":app") +include(":appnav") +include(":tests:uitests") +include(":tests:testutils") +include(":anvilannotations") +include(":anvilcodegen") + +include(":samples:minimal") + +fun includeProjects(directory: File, path: String, maxDepth: Int = 1) { + directory.listFiles().orEmpty().also { it.sort() }.forEach { file -> + if (file.isDirectory) { + val newPath = "$path:${file.name}" + val buildFile = File(file, "build.gradle.kts") + if (buildFile.exists()) { + include(newPath) + logger.lifecycle("Included project: $newPath") + } else if (maxDepth > 0) { + includeProjects(file, newPath, maxDepth - 1) + } + } + } +} + +includeProjects(File(rootDir, "features"), ":features") +includeProjects(File(rootDir, "libraries"), ":libraries") +includeProjects(File(rootDir, "services"), ":services") diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts new file mode 100644 index 0000000000..d7c17c7895 --- /dev/null +++ b/tests/testutils/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.tests.testutils" + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(libs.test.junit) + implementation(libs.coroutines.test) + implementation(projects.libraries.core) +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/AssertThrowInDebug.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/AssertThrowInDebug.kt new file mode 100644 index 0000000000..873255f203 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/AssertThrowInDebug.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.tests.testutils + +import org.junit.Assert.assertThrows + +/** + * Assert that the lambda throws only on debug mode. + */ +fun assertThrowsInDebug(lambda: () -> Any?) { + if (BuildConfig.DEBUG) { + assertThrows(IllegalStateException::class.java) { + lambda() + } + } else { + lambda() + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt new file mode 100644 index 0000000000..8a5158dbf8 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.tests.testutils + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Workaround for https://github.com/cashapp/molecule/issues/249. + * This functions should be removed/deprecated right after we find a proper fix. + */ +suspend inline fun <T> simulateLongTask(lambda: () -> T): T { + delay(1) + return lambda() +} + +/** + * Can be used for testing events in Presenter, where the event does not emit new state. + * If the (virtual) timeout is passed, we release the latch manually. + */ +suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (CompletableDeferred<Unit>) -> Unit) { + val latch = CompletableDeferred<Unit>() + try { + withTimeout(timeout) { + latch.also(block).await() + } + } catch (exception: TimeoutCancellationException) { + latch.complete(Unit) + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt new file mode 100644 index 0000000000..aea33b6798 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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 io.element.android.tests.testutils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest + +/** + * Run a test with a [CoroutineScope] that will be cancelled automatically and avoiding failing the test. + */ +fun runCancellableScopeTest(block: suspend (CoroutineScope) -> Unit) = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + block(scope) + scope.cancel() +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt new file mode 100644 index 0000000000..1fdb5879fc --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.tests.testutils + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +/** + * Create a [CoroutineDispatchers] instance for testing. + * + * @param useUnconfinedTestDispatcher If true, use [UnconfinedTestDispatcher] for all dispatchers. + * If false, use [StandardTestDispatcher] for all dispatchers. + */ +fun TestScope.testCoroutineDispatchers( + useUnconfinedTestDispatcher: Boolean = false, +): CoroutineDispatchers = when (useUnconfinedTestDispatcher) { + true -> CoroutineDispatchers( + io = UnconfinedTestDispatcher(testScheduler), + computation = UnconfinedTestDispatcher(testScheduler), + main = UnconfinedTestDispatcher(testScheduler), + ) + false -> CoroutineDispatchers( + io = StandardTestDispatcher(testScheduler), + computation = StandardTestDispatcher(testScheduler), + main = StandardTestDispatcher(testScheduler), + ) +} diff --git a/tests/uitests/.gitignore b/tests/uitests/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/tests/uitests/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts new file mode 100644 index 0000000000..729899c4f8 --- /dev/null +++ b/tests/uitests/build.gradle.kts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +import extension.allFeaturesImpl +import extension.allLibrariesImpl +import extension.allServicesImpl + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.paparazzi) +} + +android { + namespace = "io.element.android.tests.uitests" +} + +// Workaround: `kover` tasks somehow trigger the screenshot tests with a broken configuration, removing +// any previous test results and not creating new ones. This is a workaround to disable the screenshot tests +// when the `kover` tasks are detected. +tasks.withType<Test>() { + if (project.gradle.startParameter.taskNames.any { it.contains("kover", ignoreCase = true) }) { + println("WARNING: Kover task detected, disabling screenshot test task $name.") + isEnabled = false + } +} + +dependencies { + testImplementation(libs.test.junit) + testImplementation(libs.test.parameter.injector) + testImplementation(projects.libraries.designsystem) + androidTestImplementation(libs.test.junitext) + ksp(libs.showkase.processor) + kspTest(libs.showkase.processor) + + implementation(libs.showkase) + + // TODO There is a Resources.NotFoundException maybe due to the mipmap, even if we have + // `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file + // implementation(projects.app) + implementation(projects.appnav) + allLibrariesImpl() + allServicesImpl() + allFeaturesImpl(rootDir, logger) +} diff --git a/tests/uitests/consumer-rules.pro b/tests/uitests/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/uitests/src/main/AndroidManifest.xml b/tests/uitests/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..122869829c --- /dev/null +++ b/tests/uitests/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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/> diff --git a/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ElementXShowkaseRootModule.kt b/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ElementXShowkaseRootModule.kt new file mode 100644 index 0000000000..0c3fab67eb --- /dev/null +++ b/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ElementXShowkaseRootModule.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import com.airbnb.android.showkase.annotation.ShowkaseRoot +import com.airbnb.android.showkase.annotation.ShowkaseRootModule + +@ShowkaseRoot +class ElementXShowkaseRootModule : ShowkaseRootModule diff --git a/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ShowkaseNavigation.kt b/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ShowkaseNavigation.kt new file mode 100644 index 0000000000..8a33430340 --- /dev/null +++ b/tests/uitests/src/main/kotlin/io/element/android/tests/uitests/ShowkaseNavigation.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import android.app.Activity +import android.content.Intent +import com.airbnb.android.showkase.models.Showkase +import com.airbnb.android.showkase.ui.ShowkaseBrowserActivity + +fun openShowkase(activity: Activity) { + val intent = Intent(activity, ShowkaseBrowserActivity::class.java) + intent.putExtra("SHOWKASE_ROOT_MODULE", + "io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModule") + activity.startActivity(intent) +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/BaseDeviceConfig.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/BaseDeviceConfig.kt new file mode 100644 index 0000000000..3732ca66cc --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/BaseDeviceConfig.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import app.cash.paparazzi.DeviceConfig + +enum class BaseDeviceConfig( + val deviceConfig: DeviceConfig, +) { + NEXUS_5(DeviceConfig.NEXUS_5), + // PIXEL_C(DeviceConfig.PIXEL_C), +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ColorTestPreview.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ColorTestPreview.kt new file mode 100644 index 0000000000..97276e0987 --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ColorTestPreview.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.airbnb.android.showkase.models.ShowkaseBrowserColor + +class ColorTestPreview( + private val showkaseBrowserColor: ShowkaseBrowserColor +) : TestPreview { + @Composable + override fun Content() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .background(showkaseBrowserColor.color) + ) + } + + override val name: String = showkaseBrowserColor.colorName + + override fun toString(): String = "Color_${showkaseBrowserColor.colorGroup}_${showkaseBrowserColor.colorName}" +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ComponentTestPreview.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ComponentTestPreview.kt new file mode 100644 index 0000000000..8be21ba1ee --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ComponentTestPreview.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import androidx.compose.runtime.Composable +import com.airbnb.android.showkase.models.ShowkaseBrowserComponent + +class ComponentTestPreview( + private val showkaseBrowserComponent: ShowkaseBrowserComponent +) : TestPreview { + @Composable + override fun Content() = showkaseBrowserComponent.component() + + override val name: String = showkaseBrowserComponent.componentName + + override fun toString(): String = showkaseBrowserComponent.componentKey +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt new file mode 100644 index 0000000000..d25fc29acb --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/ScreenshotTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2022 The Android Open Source Project + * Copyright (c) 2022 New Vector Ltd + * + * 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. + */ + +package io.element.android.tests.uitests + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.Density +import androidx.lifecycle.Lifecycle +import app.cash.paparazzi.Paparazzi +import com.airbnb.android.showkase.models.Showkase +import com.android.ide.common.rendering.api.SessionParams +import com.android.resources.NightMode +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import io.element.android.libraries.theme.ElementTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +/** + * BMA: Inspired from https://github.com/airbnb/Showkase/blob/master/showkase-screenshot-testing-paparazzi-sample/src/test/java/com/airbnb/android/showkase/screenshot/testing/paparazzi/sample/PaparazziSampleScreenshotTest.kt + */ + +/* + * Credit to Alex Vanyo for creating this sample in the Now In Android app by Google. + * PR here - https://github.com/android/nowinandroid/pull/101. Modified the test from that PR to + * my own needs for this sample. + */ +@RunWith(TestParameterInjector::class) +class ScreenshotTest { + + object PreviewProvider : TestParameter.TestParameterValuesProvider { + override fun provideValues(): List<TestPreview> { + val metadata = Showkase.getMetadata() + val components = metadata.componentList.map(::ComponentTestPreview) + val colors = metadata.colorList.map(::ColorTestPreview) + val typography = metadata.typographyList.map(::TypographyTestPreview) + + return components + colors + typography + } + } + + @get:Rule + val paparazzi = Paparazzi( + maxPercentDifference = 0.01, + renderingMode = SessionParams.RenderingMode.NORMAL, + ) + + @Test + fun preview_tests( + @TestParameter(valuesProvider = PreviewProvider::class) componentTestPreview: TestPreview, + @TestParameter baseDeviceConfig: BaseDeviceConfig, + @TestParameter(value = ["1.0"/*, "1.5"*/]) fontScale: Float, + @TestParameter(value = ["en" /*"fr", "de", "ru"*/]) localeStr: String, + ) { + val locale = localeStr.toLocale() + Locale.setDefault(locale) // Needed for regional settings, as first day of week + paparazzi.unsafeUpdateConfig( + deviceConfig = baseDeviceConfig.deviceConfig.copy( + softButtons = false, + nightMode = componentTestPreview.isNightMode().let { + when (it) { + true -> NightMode.NIGHT + false -> NightMode.NOTNIGHT + } + }, + ), + ) + paparazzi.snapshot { + val lifecycleOwner = LocalLifecycleOwner.current + CompositionLocalProvider( + LocalInspectionMode provides true, + LocalDensity provides Density( + density = LocalDensity.current.density, + fontScale = fontScale + ), + LocalConfiguration provides Configuration().apply { + setLocales(LocaleList(locale)) + uiMode = when (componentTestPreview.isNightMode()) { + true -> Configuration.UI_MODE_NIGHT_YES + false -> Configuration.UI_MODE_NIGHT_NO + } + }, + // Needed so that UI that uses it don't crash during screenshot tests + LocalOnBackPressedDispatcherOwner provides object : OnBackPressedDispatcherOwner { + override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle + override val onBackPressedDispatcher: OnBackPressedDispatcher get() = OnBackPressedDispatcher() + } + ) { + ElementTheme { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + ) { + componentTestPreview.Content() + } + } + } + } + } +} + +private fun String.toLocale(): Locale { + return when (this) { + "en" -> Locale.US + "fr" -> Locale.FRANCE + "de" -> Locale.GERMAN + else -> Locale.Builder().setLanguage(this).build() + } +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TestPreview.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TestPreview.kt new file mode 100644 index 0000000000..fa0ea2daa0 --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TestPreview.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.airbnb.android.showkase.models.ShowkaseElementsMetadata +import io.element.android.libraries.designsystem.preview.NIGHT_MODE_NAME + +interface TestPreview { + @Composable + fun Content() + + val name: String +} + +/** + * Showkase doesn't put the [Preview.uiMode] parameter in its [ShowkaseElementsMetadata] + * so we have to encode the night mode bit in a preview's name. + */ +fun TestPreview.isNightMode(): Boolean { + // Dark mode previews have name "N" so their component name contains "- N" + return this.name.contains("- $NIGHT_MODE_NAME") +} diff --git a/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TypographyTestPreview.kt b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TypographyTestPreview.kt new file mode 100644 index 0000000000..c723f990b1 --- /dev/null +++ b/tests/uitests/src/test/kotlin/io/element/android/tests/uitests/TypographyTestPreview.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 io.element.android.tests.uitests + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.airbnb.android.showkase.models.ShowkaseBrowserTypography +import com.airbnb.android.showkase.ui.padding4x +import java.util.Locale + +class TypographyTestPreview( + private val showkaseBrowserTypography: ShowkaseBrowserTypography +) : TestPreview { + @Composable + override fun Content() { + BasicText( + text = showkaseBrowserTypography.typographyName.replaceFirstChar { + it.titlecase(Locale.getDefault()) + }, + modifier = Modifier + .fillMaxWidth() + .padding(padding4x), + style = showkaseBrowserTypography.textStyle.copy( + color = MaterialTheme.colorScheme.onBackground + ) + ) + } + + override val name: String = showkaseBrowserTypography.typographyName + + override fun toString(): String = "Typo_${showkaseBrowserTypography.typographyGroup}_${showkaseBrowserTypography.typographyName}" +} diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Large,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Large,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8fc9f60d94 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Large,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e32421c7814394247655f198ae6466f3011478ab30019d15cb951be40e6b70ec +size 8190 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Medium,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Medium,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e8fbfbf99c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Medium,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92811ffa031d8d61593e9040214ca45056a02d81f2de76c46c57606de05dcbb4 +size 8041 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Small,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Small,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..98dde774a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Body Small,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b3c972ef288022f40478133fa66c822d051e39b427d1f2cfd3434417c0f885f +size 7229 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Large,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Large,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0af7e32f4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Large,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43eb2e8c816159ee75836569f8b3264acd5d0561fec70f5645e72c37604d6af3 +size 12480 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Medium,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Medium,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b16606cb41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Medium,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06a9e835c3c349bddaae22024457c66bbed7e425dbd289bba728c2c9cc8a1b91 +size 11763 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Small,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Small,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b5d2e3740c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Headline Small,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b285f487ae95f4bf599da529ef4cc13b861717722ea9349324446ab8ba6d0e4e +size 9973 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Large,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Large,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c558773b1e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Large,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b47ee31afffe2a6d12ddc3404f73662bb416d0299e170c8832db69e4fd22cfe +size 7630 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Medium,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Medium,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a8ee798538 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Medium,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd0708db595e85815a474c9c67f12e826e24527a37bd19eb29d2a2e25480d935 +size 7416 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Small,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Small,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e07ab6b47 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Label Small,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1a99561c83e130159a788ef0b3966407d9762afd98f6039622a24b1db80361b +size 6783 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Large,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Large,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1f83156b45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Large,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3193b06fff17f1d0489569233578a71935f2d515748fe8aff22395f1ba39a847 +size 8646 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Medium,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Medium,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..55dffaef76 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Medium,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f37c0b5b314d68e49c0137d7142460ff9aba5a6301a447f45f77b813fb4d1b3b +size 8005 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Small,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Small,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..88d575ac56 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[Typo_Compound_M3 Title Small,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:058960a1b5b76cd6af96adf44cdba466ed4241632aa1ba700d6034159209ba21 +size 7242 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3d0b60c285 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee84ea7218ad52104a3f030d3daefa5174e122c511a8d7ab7515fb50e9eb8e01 +size 9580 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f576427220 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_LoggedInViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2d3f6f72be52a27e0ce15f091a44d7796f91a10eb7dab54787631b2a6d33f74 +size 8220 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-D-1_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-D-1_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..703632bad5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-D-1_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90b1daf3ab9e6377fa703ea1e13943eaf40e84ccbc61212d606a527759ff20a9 +size 9689 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-N-1_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-N-1_3_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2afc96753a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.loggedin_null_DefaultGroup_SyncStateViewPreview-N-1_3_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58a097099974131832bae557acf824154bb76fb34d309cb68bd2ae5e658d5371 +size 8347 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7870560dd4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da4188dc606f0735fd4093acad34897c866d8c4d20b3e0ec0618685f7302cbf5 +size 9288 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7edd6f9ee7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e27b1ea7bfa4eb97af3c2d433fbe3f5ca21458e4458d91b39c9c2bb3d7b02abc +size 11306 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c87156e8c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bbeb21faf320226f42aad179b955952ad8e4b5d5645c45bfbbc02166baf0662 +size 9641 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6391f1dec0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.room_null_DefaultGroup_LoadingRoomNodeViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:499e17b52d57f8b0d8984e32457b3922c0647be5b835c02a01ed927fe004ec4d +size 11610 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a4d544ce2d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c51726746993ad3ed0f6da9fe49a02b87ec518495e04f6cdb28ab4454dc72938 +size 25457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c5627b9105 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14ffd433679cbadf1bb325257786a834fce3e362d2ed763c1823b0c958c0f38a +size 27496 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c4f2de8348 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85d3ea85f270fae4db7e14ab9c2c19a6c4f3a68e1c2112010471cb69cdb71b2b +size 22071 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1aecdf6d10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd79483dea8ce19284069511ec96dfc68704a9ff2f1a33be312bbcdb387123c6 +size 26655 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..192e7b90a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:931423fea194fb1bede5cb3d73083fb65335716ca088fbda707f4f4727eae4dd +size 28757 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7ae3d51e4a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.appnav.root_null_DefaultGroup_RootLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfa6e3d8698a2327d6e3cc14318856f221355581e6c661b92d2e215daf8f30cd +size 22879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..59c4d1e6ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:940c3ac11da74a6eb734085c468050040c2d31056b6d8de516e49ddb0058c9ee +size 23412 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..726b07850c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.api.preferences_null_DefaultGroup_AnalyticsPreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97b9796def982afb2d5611db431489a2a42f80f11f949ea4aaab41fd01f87cb1 +size 23478 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..02064ef869 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:434f7619fb6bd337ede775fc343acc05950907987523acbd0f578624e5d26857 +size 49246 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..293c11b5c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.analytics.impl_null_DefaultGroup_AnalyticsOptInViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93036a30e1af96805c20ea5d65b6d3eb40d2ee11f8f3420436e936dd5b4ca38b +size 50201 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..772474c81f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92fda75e246b46ab786da2f54f920bf96959618ac20cece5f6ca754f7fcf6914 +size 14161 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b88f3011ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2a005b84bbf1be15028d36e153e79eb5acbe41e65e3f274a6d78b22be95efb5 +size 28509 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7cfc3e1c03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6bb2d3d6e83f5b0ca17809c415e636bf5dbfa846259e8fabd6c3afae37d7d1f9 +size 15231 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..965959b5ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3f7631f15054ac55688f76805a6f1924e79058881957b67284865b6508886cc +size 29249 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9e1ddd2ddb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d47034cafef80cdac5d702e52092f07ebc0bb96dbdd7f9f0c1ea2e302cbe9917 +size 33858 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d50e567f72 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84ea420320bd406588cb4d9aa3fea79d63ec37fc31a00d4fa77a06a7c8499145 +size 35452 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchMultipleUsersResultItemPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchMultipleUsersResultItemPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6595be90cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchMultipleUsersResultItemPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01eaadfd0b9508c44145ba2e6cd85a6a24fdb43f665188404f9da1cf3e03321 +size 86292 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchSingleUserResultItemPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchSingleUserResultItemPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9553e1476f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_SearchSingleUserResultItemPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eadad4490c76da04073c25dffff74a2fe5a6a0dd7612f9c5a61a631a54edd77 +size 45613 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d9033ebc30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c1e29ad621b3056562c71ff8b28b898b5aa972899a4ebeaef7fdd835ec128c4 +size 10308 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..afdde9ec63 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7642a1adb1faec60a301ee86bb34558ea71d15f4fbd3d5ddd5d1661249b725a +size 25759 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c007e13589 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed3be9611781a6f7ae4ed7c2793f76f52c02d0893dba5cbdba97d651c909edef +size 8593 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..49c2ae24bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ce0a9b678aea7899837db2d308d60c2f11f849ba001f41c077cd60c5a5bcb13 +size 7304 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..49c2ae24bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ce0a9b678aea7899837db2d308d60c2f11f849ba001f41c077cd60c5a5bcb13 +size 7304 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..59b9e75013 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f57dd2c8b41fca0a1ef74ae16ad720054a29ccfca3c38d5d16dbfbf8c79ef3d6 +size 64119 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3245d6013a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45f4c5a3edbf61d815a7b1221a73123b09c8767d15ae2d9ae04f11ae3defe697 +size 67911 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..975397c706 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d164e84631d8a95e890c909d4759dda4b8a8bc8e830913c006a5ee3948c220f +size 11683 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d41f333298 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76c3baf61206fed7f95a0ae1e68288ace113b1c617f5c8a8b5bbedf7f66787ab +size 10495 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a55c6cde99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f467065bb9c78d0e95a8a688bfc50edce522b2832c9a53e5a68899e843a988ca +size 25859 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..135c545226 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c942037766a2ced31344013441c478b8f0e22bead1fd86d1d4ef9c76e8dc8e3 +size 8799 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d7280514ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb8e2fd1cf19945aada2385effd87afb61c7c31e91096051a4d18070fe807827 +size 7672 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d7280514ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb8e2fd1cf19945aada2385effd87afb61c7c31e91096051a4d18070fe807827 +size 7672 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1b357fedb9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faf0fc318f5b70b0251b5e1334e2532f156e8ed62ef0a3608d2bf056e145f30d +size 65876 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..879d41bd8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6811324cfc868722c57a02f2544ffcbbb2f2247fc9284e4e6fe869e33447c7b1 +size 69939 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3a183015e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_UserListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081188524742d9aaea836bafa94e8c49686f7c18d805ce74c1e203ea9647169e +size 12492 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..74124f825f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0169cdedfc56ba1b55f133ed41146a0ec3597123a88ed086f7584f06d235da26 +size 57732 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..50ee002c0c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0cdeea89592fbad03e1ad29aa1616bd779403f022097ca11510ff9cda3457aa +size 83656 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6c4a4bcaba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f61e37505ee1276d894641647ec34323f415f4a3cdc699de01579a92255c2a4e +size 60941 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c1f3859c0a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:630fa772c15d8f16461655b2c2763d8ca85dc29812fea7d01366d68e3a446eaf +size 86660 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1368325041 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b26cfcc46bf0f1e3669424d4e135019c3a045fea09af5006fc90bca5f93e776a +size 21870 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..112180f866 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3199a1e88702733887ce8d29c1373711c600060fb15cb6f24fde388d706ea86 +size 21256 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..49d7942204 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5312bd01a41dc0d1b022b6ce7258435d1cd9de75bd4231ce7f86ed6f84de2e4b +size 26306 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b5b5006f6d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bb18fdd2396fd913b2e7fe0fe40a20aa100cf4df1660d69a280e968abcaf625 +size 23535 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bdf7319b2e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8327feb32afb0d33e85c75a3f05c651149440a25834dcb926be44a13c51153e5 +size 21606 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bd930af4d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b9b88deb5557da8b4670231ee41aa737543244d61abfd399d7de0f6d4496dde +size 27439 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f300f92921 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16de62092834bf803c8165e974f45e14ccfc0128a3e74295a58eef965abc10c5 +size 301336 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7465768560 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6838e81cc5f2755ff76de7254e2c8bb445b76662d7ba9b4c83443b2c2ed03029 +size 406044 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ddc9420223 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e722c2936e1332168319059d5b4a553b1fc7f03da8aff40b005a05b203632d +size 28679 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cfe7f4094d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eab30f7ec6ad8e289f4987d23f373ca8f96220a5d0762d46723058bca53d0a41 +size 33580 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f0155d216f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a344ef3f77b759e1d43733f85fc7ee9bee98d6eb3980ce13c435bb33c0703f0 +size 33720 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a38e7f59a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e5874a0e224636c5ad13f4022e8f2ef3dff97206a5551734de560fbc40c562a +size 14447 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8f6d7d3782 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e526f418f5c8d1d0c51509808e22548ce3c295752e7a1cf28cfe394fccc01f4 +size 28834 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..29cf018c14 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97639d0985e0303e3a58120fbb7a2bba67b4279d198140a9f4cf6b19aa74bed0 +size 29199 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1acc83ec3c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0372c5389850f853652ad40ad54069e456aa33e13335e733d779a8d4d384049 +size 35297 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ddb0df10ed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62f57ee9fc205539ede541baf8e4b887ef7776675c4760acd5b27545cd021df8 +size 35420 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0b5b749dfd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea79f0de34ba0613fea7ba98c65f5ff925a336ddf2d6734ea77b1a2d559527f0 +size 14416 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d7bead23cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b13ebc73e257d5f588f9e74d0a35bc9af951faece57556636378914786429e3 +size 29503 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bbd5173671 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fcd5bd88a1aea468c8f5e08a3e07a323bd0e76f8db913611929c80184c2f68c +size 53541 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0cac3ab6ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06d8d5bc2416e1a33fa938cbbe279166e3d47bfede554151e1ff7f773ee54c91 +size 8668 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..418e6be50b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3a5f5dcad110d0fe9cc1e81537319feac59c7d25084d9b77ed22c44969a7237 +size 48499 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f3aeeb5b53 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee4f54670538988698e947dd42d6d3bc585f743684e83202d48f8d02d1bdf1f8 +size 49214 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f669883f45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:442ff73662328311d0f8d9ef0000bf2be3b6141810b26f864847217081900411 +size 41007 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f669883f45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:442ff73662328311d0f8d9ef0000bf2be3b6141810b26f864847217081900411 +size 41007 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e123bbbe3f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f20e6a88d40320286d73dfa346a02783a00b1fb4a10c804c2f147e2f909c5a2a +size 56083 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..63f881a780 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7437394dc36abd207aada7f63a7367dd60b3baeff7f7a5fc612d97c787f918d +size 8881 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..816e5fe57d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c25bf522377a036e8faae35e8afe458d82cf3c761e2df237a1a4d5785f44e188 +size 50202 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a57d29443c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:904a991dd0b753af68f0ab626d1f236dc9ea273a35f100475cadad8f4332d25d +size 50896 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d61deff1b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ff0c7db1cd224a6d9897b1daf49f57fc659b25e60a9d670e8b1c66811d4fed +size 42505 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d61deff1b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_InviteListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98ff0c7db1cd224a6d9897b1daf49f57fc659b25e60a9d670e8b1c66811d4fed +size 42505 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9d47e8309c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8033b2e37b9d335f3e6120f1ae46aeb8ad114b4bd6ba665b74c6281a2aeb905a +size 6259 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d36f847e7b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42d22676a81b8c1f313f2f21814b787c54fa52f8b344207b509fbb798f2f58a1 +size 20075 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..292f5d9f30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34645175d5acb2915d04b9306d648a287cae769bffcae7ecaffd293d43580683 +size 29023 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fe795d5c26 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56f203dce2ed8ff23a75eb66bcb116fb4af61b63b1ad095ecebf6d97ba557a2c +size 31392 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ee89c7ea22 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4256d996b5432de8ef1ebf4f54907b60a514bb8a0e8deed9b6244540b6431439 +size 11752 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1393c4aa2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d23fb856aa3402cebf5c976fe32f0475f2f58b37a9ac9d028b0164dae4de397 +size 13357 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7ab75f845d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e748443031dd55fdf60f13debd2484f8169b7eb23980786ad73898004c7bcbfe +size 19615 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7b458addc7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c2419ccf59c98899071e705b63feb83e8976acca53ccb8f375551aa056f9950 +size 29204 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c64c365b53 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:598f0a995896c32b1755951779f3784dbbe5062b7039bb8952d383acf2399efd +size 31871 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d15d44bcfa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37b56d7886edda129a81c5a9cc71073b03c51cc0feaf456f3623e79e340c339c +size 10743 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c4d8cc0f03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.leaveroom.api_null_DefaultGroup_LeaveRoomViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bf0bc49ba28e3e31414b1d1eb177c119529bc7c95209a8bbefafa6fc07a6fba +size 12603 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..893f35ccf9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18db29d75fc1b8a2f79e03ebd4b7080ae76bdd01256f5a577c7fcaa1bc52f0f2 +size 278367 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bd038cd0e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-D-1_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e7d9005705286159ac8b7133386725c6ac874eb1e0ddbdfe4d4cd34b008f34f +size 280598 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ac43bdd1ef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffdebd1981c4e28216f4b16b39180d93d37358bef3a4ecf9cd58aeb9cbac5267 +size 143680 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..048dde9a5c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api.internal_null_DefaultGroup_StaticMapPlaceholderPreview-N-1_3_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a24dc75c160ba1b75dbeb13c3e581434564a25997f90788ce3866f1736a11f46 +size 146329 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..893f35ccf9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18db29d75fc1b8a2f79e03ebd4b7080ae76bdd01256f5a577c7fcaa1bc52f0f2 +size 278367 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ac43bdd1ef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.api_null_DefaultGroup_StaticMapViewPreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ffdebd1981c4e28216f4b16b39180d93d37358bef3a4ecf9cd58aeb9cbac5267 +size 143680 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fe104f1c2f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69535debd585127a4ce8b490ef6682c2e6c3c4d16478e6b9e9687ee1c1133637 +size 20879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..09e7be69f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:356756de9f08042c3c2f3033d3f8a39cd9b49c5cfbcfbc274933c3efedd80d3d +size 34534 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5311a17e6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51ac3c4bb27d78419f73b99cb24327514ce56a64a03bf74ce41f158c2c3bd516 +size 33605 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fe104f1c2f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69535debd585127a4ce8b490ef6682c2e6c3c4d16478e6b9e9687ee1c1133637 +size 20879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..960bd96e80 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-D-0_1_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fa3b5aae9cee5e2fec8929697b0606b655c45b10a65bcd641da315e98c48e1e +size 20951 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..643983770c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce74fa2b0364763152e69dcfa4f8d598504b630fa1227f2fa685bc886ccd5afa +size 19434 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..173360baef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12a78fbc2d2e84e93d40d1e075d8b46c5366f3d323dd85f27be286a64231f884 +size 32120 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0fa0b9c5d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1bd4187a3713153d6e9ed94594385c3806f59c4d36cc6f2867a38d630551505 +size 31302 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..643983770c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce74fa2b0364763152e69dcfa4f8d598504b630fa1227f2fa685bc886ccd5afa +size 19434 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ce8b5bf468 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.send_null_DefaultGroup_SendLocationViewPreview-N-0_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9390a3b8111ab0d31b3bbf3b6bf8794432d135130b37404ce4a646a74369d85b +size 19502 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5eb7294406 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:364f107ffaa4844d0141361642ce3a494a187588f27be50b5fd27d44be21fa64 +size 8887 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f317c61b6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d0a5de3c4e09d76b6453ccc6ace5c690d540d945a62e630474932734209058a +size 11716 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34c2f19861 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9218d1d514342c400051bebb10ef458c99103fda618bba45e61a524c5a58eb63 +size 11903 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..471e59a5b0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f19878925f3b5b377a91885540fb15d29a5b78d8be2282d64e8809af0bbf5ff4 +size 12195 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b56214c49f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c936d2d804bc9e98fcc49430f11ddaa572b05fc8d3a0df93ad6521ee8e78f708 +size 21806 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ea8bf3f1bd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac01bc1992e3fa27950c7071cd3e8a06b94a608238d55972816fa2a1a3175e7c +size 9448 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..342978d4f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e39c5ba30983b034886a6adc330d1510b3a48c40511697edcaf6530716c7ba2e +size 12500 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c1fa61a838 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4ff75e74c19280308878e3001a8791aaa735439cc667946fefd21070611630e +size 12698 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8ecfebb614 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:065d2a09680e35540b870862ec0ba5c54182e016017938ef64e510b6132333c9 +size 13389 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7701a371c3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.location.impl.show_null_DefaultGroup_ShowLocationViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bb88c64bc68b10b1fb709135f445de5a5e4d78623448d0fef97504e025d5f6d +size 24551 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6a9c100d36 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85794646cb82c6e084f8fcd91490e27c0c356290ee7150f480d46ea6fc472702 +size 19926 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3203770fb7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcaa30b70b58e3b494045699eab608b5f1537025bc778aacbdb22ebfef4df0d6 +size 9071 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..03cb42a899 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:963f166a58a1a81ad1ba522804f14ab8e54f04b6c945e4d0e71e77eac92bccfb +size 9924 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2081556eb5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccd8bebf827305702cda746cdd025f35b7eb5ec35db0e7fe3bbde4399832d354 +size 8317 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a447b8cc9d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c891ae5fb31fc9a9c577e995e1f41d34bedf7c9d672a96d1a52e97c80c05d8e +size 7256 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e860a04147 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3714ad0af34dbca3464a567f99bf969934a8e07cc3b7b38ad3b9d9974624db10 +size 19860 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fe68067563 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0a403b1a3bad72a2bea4d941e406f482421d2bbea5d81a1ab08b3698cbb0a1b +size 9058 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5425792b3b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9d8ccd926073e33d242860231aa0101e6dd5e8001ea088f1a21a3719c09d54d +size 10021 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5935db80bd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:406e442322e171f3d0c17b7950c869ddc408538552f1f2eecff0e546ed86f883 +size 8177 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..89b45d0b03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.accountprovider_null_DefaultGroup_AccountProviderViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7288d91ce9ba7271f1d40a7e178087d918bbbbae34f34f79e3ef567954669b36 +size 7004 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.changeserver_null_DefaultGroup_ChangeServerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..94817469d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f +size 6545 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..83c4e4dbe0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa02cee5e7c9313424c4fbd87261d36a5fb3094c149882819905e9ef650305c1 +size 8370 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..94817469d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49e2768b2a111af737a30038913481113f759a20d3cf5df0166964c75926ec1f +size 6545 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae021d50a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.oidc.webview_null_DefaultGroup_OidcViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e130fb7792bd1569ed9e7f2ac0e3b506925f6ce5ff146416296c1cbfccf4614d +size 8209 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2e72689df5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2674da6df2ae21a6ecb501f75ca6651343b9160b0ac0bda0746bbef8281aa2b +size 41009 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..639a6ecff0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.changeaccountprovider_null_DefaultGroup_ChangeAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2929c128ab7d4eecf4397f7fd5f1740cbe7385715e951c32587a50f25c6002b4 +size 42470 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..363280c1fa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52a5e65b23072ccfdc7220daca437e93342c04cee91cd971ba8d13872968ae7d +size 36962 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..232ce0d49c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.confirmaccountprovider_null_DefaultGroup_ConfirmAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea4a9e4b5488e925652ff747997dcbb58c55dd126c7b81f5222c30291270d7e6 +size 39147 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..79c5d3efd4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c60ca1b2273df93b927550e51d69f2b3a2b59f5060e0794b1cc7dbaa432dc51 +size 36879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3444da12ca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a47d3a323db2c60d2825eccbc28b2df21ff7c5f2f2da86e27c61059493b18f70 +size 38074 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..79c5d3efd4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c60ca1b2273df93b927550e51d69f2b3a2b59f5060e0794b1cc7dbaa432dc51 +size 36879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e9acc8b13b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c26b06e09a81b2990a7505b70a3bf2647e508d631e9fc65ee0a37364c685808 +size 38928 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b79bfe022e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baa415403a4ecac05895381608a24844b5c6f90c561f561668c55fe821740cb8 +size 40193 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e9acc8b13b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.loginpassword_null_DefaultGroup_LoginPasswordViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c26b06e09a81b2990a7505b70a3bf2647e508d631e9fc65ee0a37364c685808 +size 38928 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..be7a4b5691 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1bd0edd1fe6b1f1b38274884703493e8630f0936cec6100b3d942bb789be5ad +size 25454 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8609808f83 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9dfb325c54329996def82dd0cd117e83d46d37e050dc2a138c0e71bb2a41e568 +size 45795 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6082c2f9e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3deb3c6ca753f2b880816438f651cd9674781f97a2691b12573dade4719603e1 +size 26330 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e8741114aa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.searchaccountprovider_null_DefaultGroup_SearchAccountProviderViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:571150e8a2816173fa929393f741cf55468e83b5c22cb1507d5c8c5eef75376d +size 47467 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6b5e4c405f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8 +size 148762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..151e09cfc0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8edd6d72db9efaaed76ac64f9882a5b66ac30747355815955157a3f3fc98c2c +size 149344 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..847b4e1273 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cb81f2228ad63ff38bb6abe44c305adc66378bf85d36264219427be58ac58e3 +size 63343 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6b5e4c405f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8 +size 148762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..822450c8af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85222447b700315e6eea458fc72d61fb741018cb80c3a4530d4efc08ac9335ac +size 129373 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6b5e4c405f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8 +size 148762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..151e09cfc0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8edd6d72db9efaaed76ac64f9882a5b66ac30747355815955157a3f3fc98c2c +size 149344 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..13ea2accd4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d48708d932ca9111f74d75654663e66a5eba1b419130ca3f00f0d6f997f119b9 +size 64166 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6b5e4c405f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e97d88bef72c332cd145dc1080a989d163478c0252a9993f2f474bd51f2e4da8 +size 148762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..822450c8af --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.login.impl.screens.waitlistscreen_null_DefaultGroup_WaitListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85222447b700315e6eea458fc72d61fb741018cb80c3a4530d4efc08ac9335ac +size 129373 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..262abbc34a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6fe0e5d16dc3e2fcca6f892366b2742d72f32fba4b5973791a139d812d44f95 +size 7010 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fc2fe8af6b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.logout.api_null_DefaultGroup_LogoutPreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf330565d0d920c45815781c9619dd063822a53746f28087b6ae8fef729d4dd6 +size 6958 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..40cbe16de5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e635091747a867b0acbaac27225bb4a5c6e77a3b63005a03e80244425bffc839 +size 40314 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cdb2102b99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b68e89219ad178eb4a4854d8a0800975628b098aea016ea74d3f9e3e6790363 +size 46990 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f9c5109ceb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e456ca95ee33cf14cac839b2b57879d17ca47b156da65c8a870882a90ef2c84c +size 40664 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b0937d3b09 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4bcce9d53c7c094698f07de6c6a25be2c7831707581861df4b0cdf0c3d6d1fd +size 40906 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e561c5d902 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:849d063e18b29fd54021cfd0db6088221e53179563d1c044d7a10510cfa717ee +size 42158 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d4139fdbd7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-D-0_1_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63635674978670404ecff5b53e8424ce5b871b0bc76da85e43ad89e90641b020 +size 27356 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34754d19d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0321155131e845672b07cdbec0820892acd0728770befafa07dac888a4a44fc1 +size 38836 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3f28eedfa0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a51de9335637898863151a3a3ffa81da9a0743189c854d11ad666d81d9f2b2f8 +size 45228 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..410fe4da24 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66a7d55b40a9d5d7bc02f531bd3c80a1ba96e24a619c123f68549df355019558 +size 38989 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6cc64a9ea3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bb3eab588d8cc20eb2bcbbcb8a7acdf6431299bf575a124ed7aff8a4fe6cd15 +size 39182 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5107c6fc5f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa5d6c7d504ab6436cfef4787a955422d9cc6b7c652ee70afedd0c6ca776d50a +size 40651 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b6ef58eba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.actionlist_null_DefaultGroup_SheetContentPreview-N-0_2_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:352463ed8e3b4d1f999a14fc8d2f42b04a434dc154994f80a5297a182ecf4f2e +size 25863 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a3c7eda016 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8e33e4ccb77ad7b3e317ee9b70360de9b0151f7391671455f0014a1a290974f +size 395734 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0e5516377 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b57f3a4ca9ac352fe3669a9388c85e25e91e0d54f309e9ca4f49fcb79b19527 +size 16083 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8840b9892a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a968039c2fa94e89c75e395bab808d2960c37c43576b558438ec17bcd17e507 +size 184797 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7b19571de0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.attachments.preview_null_DefaultGroup_AttachmentsPreviewViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07da432732e812eaa091563610a3a0b490ca7f829260a5b61b4412d6c1875c8b +size 99982 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..749c81631f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8423bf9617b4834c42a0f0783d18c41b695cbb6e7716230a1f940b99a7b0efd9 +size 13283 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..735c9f5e32 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4990c37241c349490619538b909e62bd76f8c49ffc3f9b7a2cf8fcaf9866bc08 +size 12852 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9685f02b12 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:911217e791dc46e76b4866efc7eed59dc721795b0434f0a985078a9847347980 +size 26652 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b1f7c53c54 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1ccba126f5fef03ee24c6089088b2014911452a7c54eeab3caa49389b9859d3 +size 26253 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..882791b675 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:093b9faa97e65ad0108882964e8e8af7a980a2fdbde32d8ff83d8420cf8b6f95 +size 26425 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..882791b675 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:093b9faa97e65ad0108882964e8e8af7a980a2fdbde32d8ff83d8420cf8b6f95 +size 26425 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..882791b675 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:093b9faa97e65ad0108882964e8e8af7a980a2fdbde32d8ff83d8420cf8b6f95 +size 26425 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1835595e41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c196974276b6e6e665140f5e45529d529ffa53b91ae714bbd24921e9a81c838 +size 14418 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..01deacb825 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e9241a489f3d2fb93ce0b9678608418be5857a09e56b5726d1912599cc6e712 +size 13810 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..80f1c30896 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:162b68e63538b71e1b3a265281bebd7f56df3cd624caeafa94ab80d46326e2d4 +size 28162 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..099554b6c0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:775effbea7088ea32b6eef036968d0be99bc267d4fdea2269d3ab1101f0ee240 +size 27542 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..739e84de45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95313cb1717aabbb275b4ed039741da2ddf95af02d177f426b354701cc9badd9 +size 27947 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..739e84de45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95313cb1717aabbb275b4ed039741da2ddf95af02d177f426b354701cc9badd9 +size 27947 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..739e84de45 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.forward_null_DefaultGroup_ForwardMessagesViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95313cb1717aabbb275b4ed039741da2ddf95af02d177f426b354701cc9badd9 +size 27947 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..93e9b663f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3b5474c29a881c61032396785a5c62732fc06f1e551fd6fae8ab620782774ce +size 395492 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bbc32091b8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cde41cd981bd9ed350b6c4439cfda793946e2b7c14ea701af8fd5bb528329661 +size 395494 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..93e9b663f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3b5474c29a881c61032396785a5c62732fc06f1e551fd6fae8ab620782774ce +size 395492 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3d12f9a3f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77983bf8dfa0683d472683cdb7ad545beb6b05fdfb8b82ecf90db49d387db72e +size 395299 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1b77de0157 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a28b7439185193fca0f389d36f287fa38d29d18bfc046a125f936026ffd1918 +size 6318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c6afff3c8f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e11d4107dc8894408eac31625b4061a59e2b5eb955229d4abc64ebb1191cdc9 +size 15614 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2fff84f3a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:459ff294007ea6238f87954e04e8e13614b7c5fa7ade01f44670014013ebfead +size 15382 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8aea9ffda4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1805563eccf45f507152722511cacfa3f0411d1ccb90dd8d539f9a4a697b56f +size 14459 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..48767dad09 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.media.viewer_null_DefaultGroup_MediaViewerViewDarkPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2824676afbff473eff8c8cfbcf8b3e58b2851e37324c6a8d9ba47d626b0f8bc +size 14234 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-D-1_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-D-1_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6061c8021e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-D-1_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52acd0adfd7998d3b31569cabde3f4ffdc962317df9d28bfdce9011002f9c55a +size 21502 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-N-1_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-N-1_3_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8df9286d82 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_AttachmentSourcePickerMenuPreview-N-1_3_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ec0d842969c58a089fe9224946bac7d89ccb1041d3bdfaa1b2f5f8cf9a25a90 +size 19806 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..36c4ab2abd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:709e68f00661b6ed1ef01131dfac81a730c1ea8c107af865263d0cba6874c40d +size 9760 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a8f6a8660 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.messagecomposer_null_DefaultGroup_MessageComposerViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa5c02b5b4ec74f93bce4e27754911e96d877a43e2f4d6cd9af061618601d170 +size 9797 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5b3f3b3f31 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9034720950ce90da57f7fb3b811dddf95cb5f621a917b2fb1e930fb8aecac02c +size 44089 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3ec76db242 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd04b0d19c47008bec4e2a458647bbf4f0e4157452d380db65210e3a7ebeed3e +size 45123 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b600b0e223 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7b4b03c456266fbc7b476f4340551a9acaf731678af59961bb0890032c8cc53 +size 44669 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f0d1df632d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a3b32af711ee8de6e2efbef25c1b747883807f341a67f2fad5c343be89b3bcc +size 42863 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..682e400eb9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82183d73c4ec193b2833ef5a007facd98d61293864532a200799f1ed75291002 +size 35550 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..325e083ec6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08465420dfb59d2fa6895106ed649787659e08898efdfac5957d70c6e36d64ae +size 45770 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1206c45222 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e4c2347902204df2860f5ff48412c31b9904e61d2e3e481806ceb79b2f8d2a +size 47550 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1e8fe02f36 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c31855849005a3bf4f5eb02b8461cc2d26ba84637115d7e8390aa0732caf6013 +size 47013 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8a5d3932f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2df48ac9b873b317c7cb1163bb96d2d0fac6f6c7e00747ec8e10e0e7a22d8cc4 +size 45129 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..efd9526466 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48738c3a3a6d9caee6590fb756cef164b58d3238fe0d86fb4cf8acfac627e71f +size 36913 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.report_null_DefaultGroup_ReportMessageViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..53bfbe6d54 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e00510a5f35eb33aaef15c30e6caac62045d8e4c37c032a24922bbb749ad0375 +size 9817 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9ce9173802 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:229d83ed02804137ab1dad4114eecc6d52d6a0e3ccbad436cf226f6cfc628cf7 +size 12200 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..77a65f3b40 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-D-8_9_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27fff9f0ea88cf06934298ea6155cbf4dd49c370cc5d0a14b4d387ac8a7e7c39 +size 23203 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c02a4d8bf8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba5f5fefedaaf994fb2eae3df38724b245e27a1b03d48cb20d33f7ca49caa356 +size 9458 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d636a6b668 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19ce1d8ddd69760417e267cda7d4663b44d7a50f8ef1a5617497e1135f8aa586 +size 11500 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6db46605ae --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemAudioViewPreview-N-8_10_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e589a013be7a4b54e3f91816565dd38a05086c19d9832480713d895b1462e72 +size 20864 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a1eeee275f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e87d88a338eeb2f7122ad8ec263ed59740e17685259c0d13e79c0575b55d2a35 +size 8286 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5d739f4719 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemEncryptedViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52e6146e57b564a3cdd18352aa3db1a9316b3b4fabb58dd05ce6e54db5639cc8 +size 8198 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5bd7ff2eda --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c79bb17fd9e7e24a23d3722e382dd0537966702c70716595fc145a9e95f0e2 +size 9208 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4251de11d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fa59f1751e265d83f1a6cd87220b055b11ce7966da813f30ae9b82595ad7b0b +size 11551 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9a2e328756 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61b9ddf2dbfd9707c4515ef42b69a122361270e60a6cc7ab9c39ad8bdb133f85 +size 21257 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9aca0d2032 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:974b1a49028938d3a57f5822f646cf87189484513e60517569a4faeb23a3cb67 +size 9369 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e18146d8c3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfdfe8be3cc619c6fb0fea6ae045dd6a8b1b0ebe118853a3291c22ecdbb20992 +size 11781 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6fc9bc9984 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemFileViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:701afca2041bcc3a7d7462fb3fec01daffd98bf56f9a9dbf95746c316bc31abe +size 23077 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..affcb49660 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928 +size 138700 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb4b57910b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806 +size 185325 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..db705106e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402 +size 137137 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..affcb49660 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928 +size 138700 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb4b57910b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806 +size 185325 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..db705106e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemImageViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402 +size 137137 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f000d7baf2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f75a712d1f879ec344953714b7f17a6271f6d2664b8c3a65acd2cbe7482133ca +size 5730 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..94ad00851e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemInformativeViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:972e265d13125dea3497da515a2415dd41fb6efffc7abe74752e3613cf492be9 +size 5683 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6bdd02b8f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cf8ad6c8f8da98fdfc7a9d5c47e959aaa612dfc5192dd93b589c783d4e94fd6 +size 155690 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fbfd3b1b30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-D-9_10_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ae5e86ad1728027f7189041745592d4ed24df07e008b5798b29eadba5449569 +size 159493 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8b8209ffcc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4c033b42d6e7fcd4fece8b8023ee07b1d0477e5bdff338c353903c83da62211 +size 76073 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4794defdd9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemLocationViewPreview-N-9_11_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56875cfd798c1f5993c6630bc7183dab367913f7c58753434a7d4a08763eac48 +size 80041 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..faac8cbfd0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:daf3757b61c09cbdbe7cac7c1cd2cd3cc035a66e67a9437e7fbb71968e0ab36a +size 8543 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bda27e67ce --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemRedactedViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7450b3c8f9d95f945cda81dfae66bb20e4add3e3ab79ea2ea736a7e3bd883add +size 8483 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1eb52119d4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d09b8a24653563bc1881959846576cfc052b3712c317bf335256593b0ee0161 +size 7084 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a46a322e4d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemStateViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87ff9c55ab56c2cc31ae58ef23dc03a48f6f9117121c98bb49164ea90f3a2e93 +size 7035 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a5622ea785 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b73b4dbeb9a29f3fa032efe51180a79b79c990d9f0184421f29ed32f02e226b5 +size 5991 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..01a4782ada --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89800e6f2e83a980c796cf29e146fc64dfcf82dd290449e331a403a5701b7560 +size 7762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..184758760f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64c3d595fb898d78e2546933c2726b733bbeb369718f79e934284f66adbcded7 +size 6221 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1f7c8825cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:093ef120e0aaa359e7a4b815ec1fe07d706a823d56009c49d6333f6dc588cbaa +size 7975 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b73cdf8e8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e405ec5830955d4e8ffce7efb3769c782739ae6ddda7ff950f160c28bd91fc0 +size 5607 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f565aa8a55 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:581bfdb2fe18e6f81bae917ed5637148f59be5c260f1cefe60b4e4165bcccdc1 +size 7387 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ca1b7a36b3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f360ea3fe2c371940a7c2483593d2ee2f755ee7e39ef7e3c7dc7097d766674e3 +size 5932 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..70806c9dc6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cd0fdcee45bfc8500963a0873eb9d748d537016215ff35b7c5d77d1dbe08bd8 +size 7933 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3c871ebdf0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1628d5f5290e4a0ae796be1973760e7371b07c04025676143d1755ea9420d9d9 +size 6211 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..33478d2590 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33479ddfa595ddc6d4f8b430c766a67b3076dab47b6218213f82cb4b8f11c5ec +size 8182 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..29a9c8f23d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb6e50af0903bf32cd141847fa652ccf47a1de065a575e7a785a160772b79cac +size 5507 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fd403016be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemTextViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd16562309df336838bf1c67decf464d42aaf0f705be48fab165686c1c685092 +size 7551 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..55d3f3bec5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43df1573afee01cdf125404f84d4f9f3e0aa342d7b84cc1659d7927e194702c0 +size 8928 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ea12eee8a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemUnknownViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc43d82856e79333cbbb62e80c923cdd98f96003279e26ab9deb1087386c6939 +size 8795 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5361cd1a24 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082 +size 139282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0e14867b04 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da +size 186118 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da8a3d6499 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d +size 137535 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5361cd1a24 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082 +size 139282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0e14867b04 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da +size 186118 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da8a3d6499 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.event_null_DefaultGroup_TimelineItemVideoViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d +size 137535 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9ba41351e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:776a97ddcdc3ec5e6ffbf684c2851c683aa28dd64e808781da3b438127f5c346 +size 25784 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3c5dce470d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.group_null_DefaultGroup_GroupHeaderViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:079f861109724c35dc6eadff279354a5cd2629278c318193ce81ace8d2718922 +size 25444 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a4fe32f1d0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a2238d8d7930009ad826d46e187f6e8f58a54332f973d9b71b3c6d16cd0e4a6 +size 5526 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..945215c250 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44d4f1d7a00232e1121fb860bce4eab816ffccd3b61f9e5e42a99022cf6d1658 +size 6330 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..81c6d5c9d0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ab85ac75b5505fc21a1e13d07c1beb71a8c754d411413826306b0760fecf6eb +size 5337 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6338850897 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5c473f9b89bbc7d355a652058770c9ec1436a7d54000f7f06b3e1712c556a3c +size 22328 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..17bed9e36f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e546e55b46ef352637bc598f745540c8c052c4109712143bd91e5fa76d86b626 +size 5171 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_13,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1065fd95ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_13,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68489f028a0f8e3e4156fa9844a8f25705686186540cb2c9d126867ee4f7e4fe +size 6998 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_14,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_14,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7a0029dccd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_14,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed1f91ea938a9af2ace1c015791d30aa8152a1f1760f8b05166c4d2fb64d9a91 +size 9891 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_15,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_15,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8cb75aacd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_15,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1c0caaf89554679d55a75b0298be59c7da3dc064593e7d94bfa5351e26e70f7 +size 8210 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_16,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_16,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_16,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_17,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_17,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9c41b3813e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_17,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:520fbd04dcc25c63924ece78183b8023d995b684ad6cad19163ef8738a2be0c2 +size 7515 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_18,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_18,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f88bc71daa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_18,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7711f4ecba015ce354fc34dd5026d18a5272fe2f245412b749b656b41b2d21e6 +size 7872 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_19,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_19,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ff1150d891 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_19,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe39406a8fc2f4457e1e3c0df5e72cc07fa076e4dd41d135be0c4452d661785e +size 14199 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f5c2e823c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4339ec32892d4ebac58aeff1d4453cc6f01c3bc0e63ef34f74318fddfa34d907 +size 5687 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4e172e1b2d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcde083a9385bcbf924b131752a1b75586a3c293d2f4e4a8ab98d7783c398fb2 +size 6163 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8c95804089 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996d2ac168fd0ce5ba6913b33a3963e7b0b3ad862a38ea3c424fb0f3c3ba12e7 +size 8251 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bdd0695bca --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78fb26d395f0441247c383b12c0fc36744d424587fc74d8caad53f9640736fb2 +size 8191 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e90a32c6d8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23810b251d9419cc154291f8f6a841d632f4e635fcf06476569582f4bec61a2b +size 5456 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..15142bc76d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75e575e3463af2b6b3e187189b6874a0ac3cfa31368c0f9cf80606a0a4fc2d40 +size 6423 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e24de25632 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e82fdac9435fb5d8c60b5d728ff472eff415613c4f85abdccd665d794c8deb8f +size 6610 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..472efb7a54 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentDarkPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4386fb31a93d84774a6bf8e911ac8ce7641b7cc982074fea753a7443fbaba307 +size 5858 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ec59bc05cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efa00964cc0278de55dce618bbfbb724da99cee2959f918185e7ff3bda02c1f0 +size 5549 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3d30e6eb10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1964b6bb5a19d29fbfff82be28ddf14266955815d1b8d1e8772a927adf7e1b5b +size 6411 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3f5a5a707f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6dcc2d718913662b7a584be612c4c9cdc0680478f351b5f026870a881596d2c +size 5366 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f87ebdec87 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b0f40f802e1b5c4c8fae87e960ee37da35b596d8fe7492565b4c6c315901c68 +size 21923 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..48ffca7385 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efcee03d248f87f8cb222705909b8d70e88d1858eed137ab07ce1f05c88b888e +size 5108 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_13,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7a629bc720 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_13,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67fa52ba6d97885b2b4b7248a489cf51761dce2f589efd4108060ce1a00ad757 +size 7140 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_14,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_14,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b2cc82abec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_14,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:147a13eeeb58a4d4a181d1ce38ad7ec84bc06b0bb3e02a47c9701989054754f9 +size 10086 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_15,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_15,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..785ae4c5b4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_15,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9b674fbaa98c1049df9ddbeb59732710460afd2dcaa20c6d361747ed5cb4a00 +size 8220 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_16,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_16,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_16,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_17,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_17,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f9d723a20f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_17,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1 +size 7507 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_18,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_18,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2ad3a77e8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_18,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b90cd2c1fbfe26608e4597e87e706edf26d68fddacb5ee012d92903ffefc1b3 +size 7972 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_19,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_19,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c5b50e0df5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_19,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bc65b0f371c97f1287f8911948509b2e79a94e65565a35fabd70b831fc7f95b +size 14224 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ef5b69e088 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8a8654db5e1d534f2538894392fbdcf574be508d6d8d2e1489fc0702ceeb74f +size 5699 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0cd684e7e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa52d9ad31329bc29c1c93991937871c2a614eae37a3f1999836da271d2d170a +size 6211 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..50bc6363cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:693ac50b9dd006e330f36636077377cbdf3feca051a0ad09376cd7af736aa0f1 +size 8601 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e3a7a1b62c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d96eb87bddf8dc6f7fbab9300ec34f5fc5d56647908e0c0bef42d8aeafa36488 +size 8625 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2b3b6bb644 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5a4aad6344028ef5dbe6ff39e9bb3cc4628f1fd0ab8f7fcccf4501f72450941 +size 5503 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c3ae009f97 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ca21398faf364d73a28aa3b04ec4140c7250537b1d2b9b043457bf0e5c4dd51 +size 6431 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fc4f3c2b76 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f503eaabf593a3c2e1441a5116add6a62273ab284c08b1348fbcc8ef17fe54e +size 6419 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d2e0eb531a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.html_null_DefaultGroup_HtmlDocumentLightPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cdd0ea7d6001efa740c09791e2fc590672deec70769a72e00c6bd5185563b55 +size 5937 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..96098426cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d061141024704b6eb701ef7178bf033d68d5c4282f981680fd930bf1e4a0c2e +size 14434 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..96098426cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewDark_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d061141024704b6eb701ef7178bf033d68d5c4282f981680fd930bf1e4a0c2e +size 14434 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..90734fa109 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:355ace8380e838793970c5f5ea0fdd8ae970b2a06d26990515e032a6b4bdfed1 +size 14925 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..90734fa109 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.retrysendmenu_null_DefaultGroup_RetrySendMessageMenuPreviewLight_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:355ace8380e838793970c5f5ea0fdd8ae970b2a06d26990515e032a6b4bdfed1 +size 14925 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-10_11_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-10_11_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..45593d6af2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-10_11_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c806ee0293d94ab1c882b0f0ceaac2c7ac69d587f47cc22b4d16bc331351a2e +size 14711 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-10_12_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-10_12_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c74bbe95f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-10_12_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77c97c6c99943858530219234356459a7f0a88774332a4f11cd738b92ee15c91 +size 14200 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7eda896656 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84139de1672505f0768751a7385a7ea5bc31288021386954bd67a97aa5f5cf17 +size 6039 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f3ef2f1003 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a096548b934bb20c966cf1e399d371e624103043a0b1c8bb1261bc58c3c60631 +size 7612 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..04cac1390c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06826baa79219de34643646e0d78047125ef85112a150c65f65b1353bc4a48e7 +size 5982 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d7aa907522 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineItemDaySeparatorViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f848a4d6e49f64bc17176fff1f05a5da9197ed16a5bc027421fb385612580b7c +size 7710 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3ae6105ab2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dc7e7351f3e2e94816716cba9749499aadfb8249e0270b6f4d4c28ddb6d7ddc +size 6417 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b3c889d05 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineLoadingMoreIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c464ff6c892364dc096a8edba88074d891b9baa91f4ba50197c7a500d76c0fa7 +size 6247 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e10b00e85b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dedb3940216fd18804157f708340a535008cc753a579ced3ac4153f99bf01218 +size 175737 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e3804f7073 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_EmojiPickerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19d8a7e0d477cc7db1bea85407b71516347b158777db2e3889734f2129b69de3 +size 175762 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da86d0ce88 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71ccef44a55858e143be2b38ec8ddf580ca4c87c46f2a921ff4077c8e88e7985 +size 7223 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d3c3656694 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ce5e44fc9735258510050fa05c486e99434e657567363db17c2e64b2d1323d0 +size 6984 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..640e4d6164 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1117cc8a07fa9366e5e5e8d457b20fc51a39a85feb35a1ea6ad9e9b00ad8e68 +size 7031 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..484b79fe94 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7afe9dba88e78efd3cc3cbf7cb2ace417a629cdee60113bee1844265a4257b06 +size 6856 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dc58b108cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e360236f7ba7d9221d97d52af3403686dc7765ffd2dbdd80d3116a47b20d416 +size 7606 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_13,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..16b32c2cd5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_13,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:accc4d0490a654d6f9af16b99a510aa62b159235c76283a28f6c581cc7fb0b2e +size 7366 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_14,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_14,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..21fa87bca4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_14,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:077ab58abdd13097529a8e6b7f8d16b448ff992448dcff57fc0df6de081304fc +size 7312 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_15,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_15,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fd4ad02242 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_15,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a99a4fd8cb5da1e9d82043e2936f030d158db531bf629fe84a5618285d0b0416 +size 7133 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3d8554cb1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6073abb04d9df0889b0b6706ae11fd01f443add67e0ee1382ef6ee4e0d765214 +size 6928 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..98083964a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b68d970ad0f04806bd7b480ab3d175d66c66c62bb5c38c5509a1a50d72742c6 +size 6764 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..83a9cd1510 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f4453317cab912d9a99563c96889e0a94d9da7090f2246c4785409f73c5c17 +size 7508 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ca92ec46c7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1301c3515f6f8326cd721b76db5aa1c494ca56087d284d4b572198adcb2bb85 +size 7282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a5449b978d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cccebac6331e8ef4c5259c10ef4a2d9e42fa1a94fca8784099dca8f194da7b8 +size 7216 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4d81f4e28a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ebaf991905047abda75315cba8ebe48b51ab43e27c92682782cd53d77dac6e21 +size 7027 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0f91def57f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05c27f67f0b1e3cc0f80732210f958cc98bc6de215d0c7a10f6695b1f2203765 +size 7312 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a5c07220e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleDarkPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1ad47383e04ec91ee79dd0214cf68731fec666d2859a04d5b6c214b46fd858f +size 7062 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..58bc706ed2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fecb334b7320725d4d1256d308494757baa0e3d6c920652b1569332561f758ca +size 6677 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c689d3bff2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eaf8a0081306289119871295263285f594000aedb6236fc3f53844c98cf1af44 +size 6448 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..397b42def6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:230d354237713666ff901751c7023581ec44795f742f103fd8d50ff9966a2ce3 +size 6829 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d26b9befed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0e4d06d330280881faa3743081b9f0827156b204b3c41a32368cec85c5e0190 +size 6617 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..39a77956d0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4a3274dcb158060d96dda8ae8f32fd0c268b9db9c74581471557412e75cf89d +size 7008 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_13,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d067a67e43 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_13,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f824631fd29a3e6735dcee3dc41cf11a2e754aa599c261d0024edffc1a75237 +size 6781 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_14,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_14,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ee8fbba86f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_14,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b3eb46446c5f1ca28b97fd89c3e59a4f78e77ef03a9324eb227c57c61fe4dd0 +size 7048 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_15,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_15,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5b364ed2ad --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_15,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5827f75935807bfbf1eb37e0a9a3928c751a0a46b353bf1fca7e0b1ac5570187 +size 6836 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..602bdc52e8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67772126d7bc1743db4c36726744e9b93aa1c750a9574b9d84f94c402c9c0487 +size 6743 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd56e0a2e7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d14472ccfd1757bddf99b33b32c25366f856cdd1c3932f99dddd3f529f444f4 +size 6504 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..83a1d76eb5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36bd126b7e5c8fb01e870c583a443dc2507562c4d6dc656ae0ccd879b63f3b4e +size 6960 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6fbc2fc405 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5662045b39261928bf706d8276d74c588af17134a9258c1909dd103dbbe0f0f +size 6749 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b4ec905155 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5449c8fda65e07ab0e913257d82066b78f844e5387db8294f4450123d5cf28ee +size 7169 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..78b84664ad --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f019191dc936f5f0e5ebb0c5b8e8a408ec3c5972086a3e6ade68cc9f9ca9c985 +size 6936 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..362d708929 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ac68b8e764c2ee14d5b747c2ffc02baba89f8421c56412f120f5555edca09ac +size 6718 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2d2c1c79bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageEventBubbleLightPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a18c0ed274d5a34c46fca715ef8290b7a10dbc499c2b6806e04e236a4d125b60 +size 6518 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a2594bf0c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8378fbe86096a4f06a9b9852a8f61545eae3e6d16cea32c56ecc11535f7f0fa +size 4777 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessageStateEventContainerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b8db218eba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a0e5cb3791012323f9ad6352537b4e579608ebb33f7f46742241b3cac737e617 +size 6349 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..02edd283e7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfdd3edecb3cb2adb7686dec5043407e3a23e15c5a7911a28f5feba279532e9e +size 7261 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..23ed8a0354 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:995f7f218c005e18acd4b500a81d28784b3b3d81a12c2aadcdd9672ef18ebf28 +size 7191 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d6134420b0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-D-3_4_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e08bd85cee39e0827fa2cbbee3fd60dc83d860af553f52a609a0f4d691174d2e +size 8140 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4cbfc21652 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:211784ef45a8d357c2a9e9d8b4a16ea10be71d14613ab1e32119ed8fa584831a +size 6365 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..584b363b9e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e2085deec0b6096437c82204e8da30e143f3c669c9759dbf34c26d63bbf4442 +size 7232 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..666ceab647 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c01fa8074194cf65443f2f321d3ce6b313b7ade8aa7f41155acbfd2a1728e210 +size 7147 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f3c7b5121b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionButtonPreview-N-3_5_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47ba3b562872e6078e48bd3168c3da54515ea7196fc5310e520a1876972bad16 +size 8020 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-D-4_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-D-4_5_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d732d1c37b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-D-4_5_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:724bc9b0db8f581119201a8db3c405c9a7e7261cccea45a354fd37a5e33fcb41 +size 11141 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-N-4_6_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-N-4_6_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b08510703 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_MessagesReactionExtraButtonsPreview-N-4_6_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:109a1a9c6359a9fd7fe4258f022e845c9b8142f9e590a03e5c2efa9273cb572f +size 10742 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f15ddfd706 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6418097195ef3c6a166d216913469aa3adf30a4fb42fbda21bc27e321d431410 +size 9792 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8c614712a5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_ReplySwipeIndicatorLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f64649306919ac8dfeb6a7729f73a0617e8fc809c2d62f3aeabac6566bced1e +size 9151 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b2efa59afa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2b4a4f59ce3fd873b1a2a6ec9c316f233236c9b11e48c33b29343b26a0be748 +size 5402 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e980cab401 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8a0ccfcafee93f211f15103a522d18afbff822f49e7cd21455fa58d067a7802 +size 5880 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..31252b2620 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb6e2e9293cc93a6c9032d9ecbb478b25ddaa9129af8d832dea175169304131d +size 6720 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..34e6623b34 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa75fb5174d8c307e99b4846e7128bae29e3a075a8811a646f041247ff0caf1b +size 7194 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f5a4e83f65 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94edc0d3ce376171aab93b9b50d641858e7ba9012690790fc214789ce5f9e9d3 +size 5381 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8faafc3c56 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e00fc96358824ee897e4e7f822adabcba2dd5fe35204346ddd6dfb56223caae5 +size 5883 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..caef253e56 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0576dddf1f46a1b8e6dffa935b43f6915aaa7b3aa7907c729f54daa60a12cfca +size 6716 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e73da6b9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineEventTimestampViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3443a7172020b78b4c30823b5f68d2408277b1a6a9984466905e6d229516c44 +size 7322 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3fc8c841f2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38c6d3f4a47ed89c3deb4150024b49c0a533a859f21a1592a82309b8c7316ea4 +size 152242 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b204098bdc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f82840398e396e038ad68a5fe855394a0088c413e7aade339998b8ed63fb1c09 +size 157273 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..65dc22a697 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:527dcf9001131276820a7853ae40a91e906ce9398609e439328991beb75bdcbc +size 62184 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..62f22bc84c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:116d47547b64ac8f5403acfb79b1e1f0cdb6f8de6dafb3a5ca59351a6fa8cc4e +size 64207 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3b0e147157 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab3ce102aa2a3be65e52d26f261c5860f022bd0755c8069ff86c7e75bfe6952c +size 68744 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c5800c7bba --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08479dcbaaa215af9df4d8bf2d257b0eb3a33da55dcb449ea5b6441972765636 +size 70616 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4e647ccf76 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37fadb92faac0c5f85e1e198170d4e10b2b7d54bb3d97e3efd3a0c02e4e6f609 +size 63795 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0649ba7e14 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90d0cb45a49afc59df03cd4caa6bab215560cbd13c259d25a7ec7990e551df91 +size 66397 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c379b54986 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86b912e31d126699fa2e7858d78e703676ed220f159994b2e8ea885c07054a43 +size 70816 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a4535fed6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowTimestampLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e830ca620428646fc06ff1682354445bf4afc9566c55c7ec56fa87aca4cb4167 +size 73556 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d389368d7e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c5ef7519aef3badeda3983b2ec5474f31404bc06b4b46b9829848dc7884fe1d +size 81749 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..722a25f340 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithManyReactionsLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9e9b769422e4714e87ace97058e5a8f064e3b927e3a7c6042494de8381d6b09 +size 85856 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f2add007e5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4970830b042e7b6588c03bf632ae3ade5b39de7339e27618ee341cbd9861c0e9 +size 129351 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a048edf7d8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemEventRowWithReplyLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4aba48e03e8424e5ff131c22fd5a606280745dd318f5822e2167f3b39794ff41 +size 134569 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-D-6_7_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-D-6_7_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c838261a0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-D-6_7_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfccebdef9523a984d9edc2414aa159ed025aec14f891945abed73472e462df8 +size 13641 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-N-6_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-N-6_8_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..001fbca040 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewCollapsedPreview-N-6_8_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c971ed744305a474f182bf3d6fde281b9034f99cee4f4207460a733ac8490a7 +size 13464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-D-7_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-D-7_8_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..53c52d9a7d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-D-7_8_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:999475ddc51f74a590e5ef2810ab31222127e55cba01fadbeb8cb8e9058ad6f0 +size 27170 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-N-7_9_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-N-7_9_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cbed0a0752 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewExpandedPreview-N-7_9_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fb7202b53005274d98f0aee8014326f02bb62fcd323b38b4d8a447078499dd8 +size 26965 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-D-5_6_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-D-5_6_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..119678a386 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-D-5_6_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82550de45d39c52f34fca45f84b6e9eff53dfef6d2e9622dcaf49c707927d665 +size 7873 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-N-5_7_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-N-5_7_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..896fd1051e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemReactionsViewPreview-N-5_7_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc39041ac035aa372640ca7b2e9af254564aa10ce7a7f4c55fbc4bca9f6dc6d9 +size 7844 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9aa828c75c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b968e5091c0bb61c53125646a3f63e81c7f6e856fc1f62d3cc291b8406ff5cb1 +size 7240 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..232b860c19 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components_null_DefaultGroup_TimelineItemStateEventRowLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5288cd36562136c4a4f07b5d8f8ee83e9130fa6c2eafd2bc13475edee8c62a7 +size 7082 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6d3ee94978 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b01bea526de8667b14a40b3e881573d07cc259406ae9e48bea002fd9489de81 +size 32617 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..09f0819ed4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.debug_null_DefaultGroup_EventDebugInfoViewPreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c3c2e3808a1bd2357a62df9bfae17cbc70ea51188cd7768d9a97589b6e73179 +size 34622 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7c99f5e856 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54c12970e3563de958f88e4c538dd368f9810266060627393256a91741f7c6cf +size 53340 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..def9cbe0d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b7ae3084cb9d1ecee2e4db49c228516bdd7352683e797edf737c8d216922dec +size 65601 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..258ce3f3de --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd034d439c08793e0dfd59f6bd5dcd88c06ded6cbe98172bf3cf296888e6d575 +size 51244 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b2f16a9d23 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9d835cb1a420117b4d967181e2ca0fad71ba243d6a17bea08b82cae41f6b8e2 +size 68760 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3bbd94a1c8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ad09278ae2ebb8171adba96d9f6e91d0cc4f120b0b2368087796dadb37eeb87 +size 58539 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c32b14777 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:145856c3a7ff43702403ee5b86a7119b0475f03fdbc0f2e7f84e10350a64b150 +size 229842 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..53f57d95d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1f0939b0c22ab89466953889e5bb63e11c45f02af79eeba3f377409af61d356 +size 230809 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4c991e3c30 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:082206122d4e6d9e6171b3b2444c576ff7bd47fb3946d8d98e2812654f39cd40 +size 73641 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..80eeb03c19 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc695bfc589e8e5a852eeb9b2828ce968d17519bb5be957cf2734a7d6d9cc356 +size 89482 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..727cfa4b3c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9efb4310c2eed085f8f72be66d725c628e07373cd18c262c18be510d7b042f69 +size 393373 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9c29642146 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19982d091ffcb15a422e75461adb3d039775645eaf74b6bfd4a3ee617dd4555f +size 347932 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ef081b76b8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a243d53d10ca2eaa249b22af8a9fddf1a8ccf60db4f3ad9374e31cf494fe878 +size 54909 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0b13a93b0c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-D-2_3_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fdd478be89b47fcaf9725ca14c1e775087f1ccf2a266f3476a537bbc2b29922 +size 67476 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9f5b7d48c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a3b5bbcdd1593e81384b335045bdcb0b3e01782868993e9f6437a15ca39dbca +size 51380 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ba9f8a8d63 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e26db0a0a8d767d6e732c1d2742cba3c3475d761b0c3017ff01cee7e3d362a5 +size 62773 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7d12d81b18 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d5cae7d73178aecc12fbf5f1f27c9e66608b90d462deec862a881b403469e93 +size 49475 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..44fd92b664 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe9456f4446104142221e28167a578a2b5cac772dc35aeb14352c0d422dd6fb0 +size 65726 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ee3a9ae3fe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cad3b5ce023d890fd4a5ff0f5bbabb678bc6d5c76d3476e8053673c06f360c8b +size 56102 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8e578cb1f9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71b3c712fb8e4bca178afed2de6f4184bb717e3415622e97f14bb4fe36aaf9d5 +size 229097 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..13d92ee329 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f44d772e5b9fe65a79e05aaaa82536b19382f9d821c0412c47a7d330d539ffd2 +size 230080 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4a58253b47 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41a6c2dd81698696708802276b615a78ff22cc7cb8ae2d4b8d8cc862bfe44a24 +size 70856 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fdb8345d8c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:167f8368e0b94aa05e5d1cc30055e3b0c2c910752d719092ef2d34aae09dc832 +size 84649 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a6187d9a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ee754e789926bfd12212a21b6ff882ffca337d6903125ba5e1d7cd0c1c218ec +size 189364 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a65dbca59c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29f9ba8c19287b037f67aec5af4469174eb878b7ed5baf222f66e159faee6e16 +size 178410 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..08c13f8f2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:789ee5ccad8356198cdc0634b4e9a65ed44be2d26e7ce83a8662598c1bd8d4c2 +size 52765 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..300bcf7167 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline_null_DefaultGroup_TimelineViewPreview-N-2_4_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b40a2e5d60a906d7c35c3ece1854671f5b319b00ad077322d094cbf906c07f7 +size 64803 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..101f913aa8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c724bc77185a9ceec2cf092cb1d7865b13718d5320bd7ac4850bb85590f05b2 +size 52294 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb28314caa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a303894134ed06348b609e41cf109dcafcd994c3dffabc6d9ab436fe92605245 +size 53710 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2ba437739f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7523ec0d6defd7074af0c804fdde64fb8d421f6cfa729bc1c4f9858bd87c42d0 +size 52554 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..73715a33a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f48089147f7e089abfa64254143a80857ccc9f840aa60403e1aabc67e2b6d51 +size 55458 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9ce6f92309 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ff682ee8363d450bb76db72ea06deea87fa47692ce319b7dff315d2a10dfb6a +size 51033 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..57eeca6786 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e84edf8adf1a89153dd45272d36e04561d66f2ea765ad9edecfd8d750ba99f97 +size 54237 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8baf81c8b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bc9521bd1576d47ca6f643adf43ce3638d40328207d908ec863c72503d34f24 +size 55682 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..40900d7e92 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2209c3cc4e7de32ed92b3b0da4616b54d19091d43d21c0e4013728450d0d3f7 +size 54595 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5777c7f77e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7bdd0ca39534b31c9d421e28337712b1cf2aaf841a766fcc6bdaf996c756bfa +size 57524 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2e2bfa89cb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba349f81d5c417c612cae1263504ea5b4e83dc606ec20c3942368e8992b87ad4 +size 52886 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewDarkConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewDarkConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fd00dfae33 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewDarkConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e0c73b8d86c064ce46ade6477cc91803d543199657e597a15a7e21bdacab7be +size 6541 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewLightConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewLightConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..820b688066 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.networkmonitor.api.ui_null_DefaultGroup_PreviewLightConnectivityIndicatorView_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3af4d7ce438da56851aa3041138558d8e57ac181775476b983646039a1cb62ae +size 6602 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..636c99bead --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75bea68dfb5165f0f8c755237b627f5c61411fb2ff4d55a18d1daedf054d15ff +size 338382 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..14d6ab78da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66e66c919bcc118c87c1d1095d03a105d42f3d630466f0a08d2cef011e67e9e4 +size 327542 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5cadf29e4c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c3c01fb242bd0be1dd894542b3980d359e9276a55afe019ba95423eb7eec7a2 +size 340958 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..68cacc0096 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-D-0_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:595309f942c0ddc37965b8c1e2ef7afd2dc896929e71efd0696ad9214178c9ab +size 322751 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f32ba6cbaf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:69c933682865e48212cad79bb2194f2bb20a8133e7c76ac1f53f8691445e840d +size 421976 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ebb9513813 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afcb322f07f594e26b051146b22919daf35317c844669e6204c1f4d29f9fb7ad +size 404245 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2c884eeef0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88d2194ffc00644a2666175e86ca49dfbad8988fbb98346128d0add77c79b8eb +size 421289 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5ce6c750e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.onboarding.impl_null_DefaultGroup_OnBoardingScreenPreview-N-0_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c3c17831149c3a6a5b058e8b557b357e84157cb6a0bc56b945c9e2dd3bdc0de +size 393706 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e3bbdc845b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63f37eb39f73c5c4e371dc3c2f6b8c1ec5e58acaee666af235b8b15a79c749fa +size 15970 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e6d462e6f5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.about_null_DefaultGroup_AboutViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dd1385bea0ebe1481c52a16cf32f30e1a9a6812cf3618e472dd321078164830 +size 17314 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..923e05f0fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba708a48280a4f42196d61b0cfbb7f0bf6e4a818b8e76ba981e5cbacff37c9dd +size 25493 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9358cd9f18 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.analytics_null_DefaultGroup_AnalyticsSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b75a4207e4dc3fc0e3a124574e0352eeb7e731558d90b4d19060d3a046b76e7 +size 26656 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4fb8f691f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb77df9e072715ed947537e4474d504b5b5d533bfe9cd888362a421fea5b53b5 +size 44068 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4fb8f691f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb77df9e072715ed947537e4474d504b5b5d533bfe9cd888362a421fea5b53b5 +size 44068 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ff0fc9708e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:004a25de04aac1ca9cbbad77581e0b52a6f8fba30aa2e64c03a791c54b297d5a +size 48875 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ff0fc9708e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.developer_null_DefaultGroup_DeveloperSettingsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:004a25de04aac1ca9cbbad77581e0b52a6f8fba30aa2e64c03a791c54b297d5a +size 48875 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1b3ca22f31 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8417eee1585f6ec9a29ea1e827415f8b108f88688af9c3b9f5740f499cd1df5 +size 35126 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1e2c770460 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dc2cf5f31b6183171a5b92197f30bc03ef8693be50d1a6e9db4860a670f7440 +size 34516 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6f44c3c747 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:050cb885911e011f25e302fee3ec060d74fae70b7b2405927fffdf06ca3b0fee +size 37187 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0221769922 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.root_null_DefaultGroup_PreferencesRootViewLightPreview--0_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc612a9d9b82f54373fb92e5d4519e331c315391afb52067b2e22c6e0379f3cc +size 37336 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cbd1ab9ec4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79de0ef9c06b6e95d02f6a3dc27d10d741623de162a5597822a50692f2cd28a8 +size 13089 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1d8ad5d01f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d38b0802eab518e2546629197b418d7a1b0369d921b199dc59797bae0636ed94 +size 12435 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..23cf3ada3e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd987ad48b569d423d1c37743d0e0b862600cf166f357c997435bad6841e3d4d +size 6001 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e47261e624 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a56c4aa5117ec8ce4d63ee679443b88eaddf84795b21f4b61429ae10ddfd2fe +size 12831 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e8c8e656eb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3b82fb2d33d5abf66035fd26b7eab778f4b6aa67f29b05e0b252a9cb57ce365 +size 12957 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c1d278b09b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.preferences.impl.user_null_DefaultGroup_UserPreferencesLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61a986107eceb0f6eb11b0807946a84a0c61887fc5c1f5232c70e180c2f124c5 +size 5793 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2d5adfdf5c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf048607d3003004608786fe123bca039b5a6175aa52194ca54b4d54a69b7213 +size 24135 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d96d3a80f6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.crash_null_DefaultGroup_CrashDetectionViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57c119c7b2291b4a6431f9ed7805e74464a175e0a169f147ba77627b9174d2bc +size 25074 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..aa6c59c6f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8415a958d4157f226fed8456fc0d004c1163d1ab9e24e57bfb2b369b4f7d0eb0 +size 26039 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7b5233f7b2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.detection_null_DefaultGroup_RageshakeDialogContentLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2de43d9a1e0652b2326f6b5462a915296388b61806ab2c8c62e4a7c15808a8a1 +size 27112 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c2b87ec530 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6399530b992aff076af9a57a1267aa9ef8347b5d2a693e153ddc1607e25ba41 +size 18524 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..420e4f4531 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20feb0126811049b238303dd11824cc65aadc5b7256e08cbe2c604452fbd712a +size 15109 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..16ab7885b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e631b7635ec2d9f636c5c0b5174c7f914a76e1fed843eb209805289023b4fe75 +size 19347 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8dc30bb185 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.api.preferences_null_DefaultGroup_RageshakePreferencesViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1338c3951801ff9872765b20508f37b15132c61685a3d79f80471f3939d8f249 +size 16072 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0a43709e00 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e1283e5ac86cf9c77b0695a6622682e7257abce1e7787440f50c8f42ca0291 +size 64632 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e26b93adf7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dac098de353f8dce2d8eea77bccdcebf997c642f05afaa15c02a9bcf230fa37a +size 200158 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da4cf4461a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c4b2f7af207e8d2f951474575de12969e0fdf0831ce68fdfde5b67b364c67156 +size 55013 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5ebb85150a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91fcd5dc74788d4f597dfc265a1d6a7511e5f4db47323d930f7c54c7df7d62bd +size 67464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b999544c27 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3583a0758b9c02c7e165dc593f7a090a54d70bcfa0f39853ba3bddb42850d404 +size 204354 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..784d1dbe36 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3dac3ed1e6c46265e8b0f7bb479042271668178ff2ad34baae3cb2d777c7902a +size 59091 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.rageshake.impl.bugreport_null_DefaultGroup_BugReportViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1bbdba6045 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07ce6ce83db08f8801f83dca8256c3fd133fc3501e237db6518d482f2248fc76 +size 29665 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e10aecaa41 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:378b3d99b8a5c4f30a6148af614526a376b02f30afe97f2d6a7c10c097d70593 +size 23386 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9e1c8d9f20 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e07de26f3432ed6b5a6b85490a52ea7324d11ce7722657770ba88c0ebba5f37d +size 54138 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3cad85cbf3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:685460d930fee9e3c2f982b2245bce5c47e1a40570b5a69151cb3b12ad0cf2c0 +size 28315 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fc0e472e08 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7ef8974e2d69d8ae325a3465088c93018312716636fa1624d5fdf66813aac4b +size 27887 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d42d35871e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d2d88ed1abec3c370b0794ad81647a4d6885545b57f171ee98907c59e8a689e +size 28903 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1ac04e5716 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90246e3319170b6dbad8c4a18db08f82c1d09a62856f6b8f457479b84e81061a +size 25384 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..70780621e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ea01e575d960dc098a747b6f37891535e2a1028722d9923c92b81c97b99be3c +size 30740 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e5687588c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af817f9b053559a95b43bc40d4ad289f3fe72f28cbd6088f4bc6fceab52d2c48 +size 24201 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..aefafbc1e3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbd283f464eefde6a8f99d4546d89de62aa88aa57eea87115a11ac2de90c02a8 +size 55627 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a71e49a925 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b334c7d8bb45aef8b7c6bb4f9b016f344283e36b45f762c49b0acac195b214eb +size 30407 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..db80f0d5c2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7198551e3d09343b6ba499349d7634b116d42707077ad31220493098051b3bff +size 29152 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..99e13a9113 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4493895c285381147d7ee542166fdc946b1fd1a8bcbdeb23df391f63e0f731fe +size 29369 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f5566a7ddc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.edit_null_DefaultGroup_RoomDetailsEditViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4131fae961f950e77a6c435703fd6193e9bcee80b5c0fea32bc9383c4db787b +size 26352 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5a29b71fcb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f19d6be4e6e65007ee9548cb6494c6f760f8282f24ada04a53089a142fe8d0b7 +size 14028 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..195813c9b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:445afeb50307433ab21756b60d2d0f1f365db74a99d81db9ab994927d823ccaa +size 28495 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7158d39702 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2947b905a604c522696036b5ab408ec22d2ec6dd8b75579c5727c6626776abdd +size 11520 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8124a1de37 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c7dcf21a708c134ac856b5b27c398330c73f25a69d9d46fc839e7aa8f2c02ef +size 26291 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e8ebb07f1c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ea55df98f4ef544494dabbf661c1623d567291702a68993ab65f4a67770954f +size 13576 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4c91cdb1a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc5794eba22329603285fa264519fd9c4c7dae4ddb867ebe54f5c87f4af646fb +size 45438 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..58e7a0d034 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f67bdbeedafdaf59f1bb643d86dbfdf10442900d7d426e2e31cf62ed5fe43d9d +size 38555 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..751bfeff5d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9c4c8b226405f3c92536e329eea5f8ed73b66f9bf8b18c484472212551ab13c +size 15034 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e722eddaf7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71612a9c2685e71e2075d8f592833448fae446edeee64f996626fb713142d02b +size 29353 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..85ea57bf0f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dee1f76c085ea1779e79f6e3f1f67d63c154989bb07efff4af8261073980ed84 +size 12354 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..767762b05a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b1dd6dc2e1c333203063f86a1dcf442ba6aabaf1a4210a8d6429f402ccda5a1 +size 27229 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..596de325e2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a0d5a7fe904c98ece401a8e91685f9f15f02647eb269acbc20d93ead9d5665a +size 14439 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e1241affa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af4c302e55c391b0aa69a418f80f8df514157a9c30d3f33b28cf6c322c458182 +size 46942 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..37ba85ea50 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.invite_null_DefaultGroup_RoomInviteMembersLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aca6a614db1bcaec2a0b8ad4c3a64c761fda757e32865f535e75f470fda97774 +size 40683 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7c5af2c6d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e61170e31a1fb6d697e08c73e13dec40e88759f880a5c1720788a3b231800d7 +size 19587 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0e2b1f9c70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37722b8d9c417f8b1a22083dcf9f1d48b166e39c64f6e11713b6f85b6182ce6f +size 17510 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1eea369e16 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d979a957761c00848ca0ef3b115b5787d5a26eee5e4d4db6ba5b837c1f05be77 +size 20035 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7c5af2c6d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e61170e31a1fb6d697e08c73e13dec40e88759f880a5c1720788a3b231800d7 +size 19587 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7c5af2c6d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e61170e31a1fb6d697e08c73e13dec40e88759f880a5c1720788a3b231800d7 +size 19587 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..50217fd9f2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7ca068387cff8faf728a989488e7c4b5b07983c4b24162ff82a14fd90b82d05 +size 20609 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..10c5bccfea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57a16092b5e13be5f76f4cc42cf3e0c2bad983695a6b761fdb400a885c239ddb +size 20078 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..044efd7841 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:802a6878cd7eaabdccea21f1d430f0e498ef7470ca963c6b2b869913d2a2d0a9 +size 17859 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ef2f9cde70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1e93f5807f4a1dad7be55a918f2bcbdfb9ea1b95140bab26005812c3e363e1b +size 20558 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..10c5bccfea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57a16092b5e13be5f76f4cc42cf3e0c2bad983695a6b761fdb400a885c239ddb +size 20078 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..10c5bccfea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57a16092b5e13be5f76f4cc42cf3e0c2bad983695a6b761fdb400a885c239ddb +size 20078 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cab17c4d00 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1599be0c5a37083c015378ee47a78a90dcf670f4df5d85dd25d39f4b2edbdf6 +size 21147 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8952186a4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8850f5c0e52e9441f92010bebe8828cdf5e3715250aadb68f31d996ec1fb7520 +size 38042 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..19e71c0ae3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66bacd8bc5664b3f519b6ba131ca4805edb88934cad8f66ba85fd7ad0a168da5 +size 13915 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9db420c070 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41c107c84e391b34ca13210b904327ca348625393339b95de8e740ff231c53e8 +size 13120 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..32f9d1754f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a8a91bbdf35954d3a3b54b48f090c4643db71112407053e7c63402bfeab1f55 +size 12267 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c007e13589 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed3be9611781a6f7ae4ed7c2793f76f52c02d0893dba5cbdba97d651c909edef +size 8593 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..49c2ae24bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ce0a9b678aea7899837db2d308d60c2f11f849ba001f41c077cd60c5a5bcb13 +size 7304 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..55bbf7b806 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71c692b96eae1a8ca48e26d75f5a39252aaf8e742f43d009b9dec6a5723ea99f +size 24919 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..975397c706 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d164e84631d8a95e890c909d4759dda4b8a8bc8e830913c006a5ee3948c220f +size 11683 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..864c9083a3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8338dfb26935d63918749a3dd0c3d06d752172aba69bc67969b4928667fa8da1 +size 39183 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7c6374e930 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd5f4fca6897b1124940c763525c2c2261141c290e67e507204c2dbcc77840d9 +size 14767 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1fb5bc6b4a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1ac52a5f865e8adcb8c6429e0d6a7f088bad2f0607b3362f899f3b87526aec6 +size 14016 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..87fbb5e608 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a311823a9bebebbe3adf44dc67d747519d33e67c3278fbee2964ae616a4a20b +size 13057 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..135c545226 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c942037766a2ced31344013441c478b8f0e22bead1fd86d1d4ef9c76e8dc8e3 +size 8799 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d7280514ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb8e2fd1cf19945aada2385effd87afb61c7c31e91096051a4d18070fe807827 +size 7672 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..76065dda05 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:216f4f1723d9312eb501e3b6e318bd3e37685c271d5e2ff72f939fbb88a53cad +size 25660 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3a183015e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members_null_DefaultGroup_RoomMemberListLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:081188524742d9aaea836bafa94e8c49686f7c18d805ce74c1e203ea9647169e +size 12492 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..41e47678d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41972465c065b84fe047cf59394a7e83c036ef21b2eed5e2ec8c8e57f76a1408 +size 53943 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e4cc063245 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a744e1072897c73906d1e87931586c9124b73c066e0fa4ab7d2c5d41d4abb344 +size 45347 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..13164ca1d8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b0d8fcf96f22d9f7c4d8b417c2dfa8956b46866c0123ad365122c3080d4ac25 +size 46231 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..190dfb7155 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c134e38cf600120bf43d0329042c6b24ccd2fa25bd48c70c028606a8598dd546 +size 48409 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4f41e68f08 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1e1fe5773827cae3c3f995bc099538b4dd5c578d6357b54a70072aedd59882c +size 60035 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..85a3e18440 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:855bd0ec680f2e8f53d7e186c2f6228839f39c6e7b56ce3361f6b71740584ccf +size 60275 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..85a3e18440 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:855bd0ec680f2e8f53d7e186c2f6228839f39c6e7b56ce3361f6b71740584ccf +size 60275 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ec3d5c8824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:368f31f5485fe14f49b727c05328027ed7026bc6ec466f0c08d013832abfa084 +size 49190 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8d253ef56a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsDarkPreview--1_1_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e7796a991784b30ec4117d304d7eecc09777019703bba1eb97deacf175f566c +size 54205 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..10ddb50653 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1a1489ff6a9e2929209aac79d57b4ec8868e7a17d4830bfac4cc764a1af4714 +size 56054 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0955f6d69b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7862b2b99c0879ec4fda3904795cecb87a7beb6dc317635c4efcaf815a7eb6b4 +size 47428 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0ec49ca16f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7e22a7534c46b166706ea0161f57b1b826d31e2a6ed971afcec7878049e820f8 +size 48394 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e8ac11ef0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34862731fdb1ae399e38ba2f45fef6a976241652b1188ee66e3d8be4c5fb49d1 +size 49668 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c4238dd38c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fe5285eb6de9a9b5af5bc21a28a97e75c98a75bb6c15f88289b29a02c81d476 +size 62414 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1800fdf719 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10b32d81962ba03e246ee359ce64fec133345459e5bafc9976f8c9e1f2409960 +size 62345 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1800fdf719 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10b32d81962ba03e246ee359ce64fec133345459e5bafc9976f8c9e1f2409960 +size 62345 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ad0685df73 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:297ea8debad4e91466865615e197b51b4ef719fbba45d26993f4a7d82e6c9a85 +size 50504 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..240dbb251e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl_null_DefaultGroup_RoomDetailsLightPreview--0_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b062c4ff23b014d270fa414cef3870aa47eba6c5096075c1508000d16631d8e +size 56292 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fb901aa412 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4276dce839b402dd3e272687202d3c9723594e901f65b441f284f621d9d1112d +size 10553 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..29be05880c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_DefaultRoomListTopBarLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9caa994e88fb1cf0730eff2df744a99c57431b007559c3ec69435d9d5686ae1 +size 10421 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..82120c314f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a7fa18ff871febb39ed15e617a873cc77b7a2bb1f8263ca6078086d5cd9b7c9 +size 28752 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2182656f2c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_PreviewRequestVerificationHeaderLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ed90625ca0a30b12cdf53c34afdc7a29f410be83588adf64da7290345810f15 +size 28893 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..805e00c2b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd7f6969aa48fdde75e1ad046d595882ebdadfc310cd6b6753f43895aec7b99 +size 6281 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6bfb7eeabe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryPlaceholderRowLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c978bc799ab79290f89568de692d3ace219a4192ec9706fef63fe977a853f74d +size 6046 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f473a731e9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17eadae453f24c112991f8f60ea7056957238160c7020d797c93c10274cefb97 +size 12110 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..99ab1d7102 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad7dd3bdb9cf2186daeb20d8aa9f0e5a84553a1213d2b5ec0023e61ea63c3a6b +size 9397 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..09816fd95e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9b9f501d8ec0732840b3fabd52a7534157510ced682752aeb0ef455e97eb9d7 +size 12502 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..667b84c5a5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85bb13dc3d60befc76b0bcc45a1367520826a1cd7ab33894c87f2d014eb57789 +size 13394 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fbc3604395 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb01f60a61dfa54ae5ec2392cc3dd887d08a0a098ea55bee71053d1e09d13e7c +size 13713 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..805e00c2b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd7f6969aa48fdde75e1ad046d595882ebdadfc310cd6b6753f43895aec7b99 +size 6281 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb4b88522f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d33415a336a78470b53298e50797e212ba098995fc12173186215ba0aaf8bd7a +size 22207 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..900a071835 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d127ae4e281d8b8b62c8301821596a477c55f8bb3c2946b5ce63e7369577eb31 +size 11901 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4ef8e9b032 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2ec0797ca403e04a55844365ae10be599f0b3f18abb9cf75b45fc2be47a2917 +size 9253 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd9b7a5ac5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9610d1acaa1b04c214ef529e5b782babcb3d0c9446095c0e1ee49b06194e7b3d +size 12324 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9a2595941a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6624ada3d9bd02ecc20a30885702961ee301793d3dd385ded5b94c562aaeca49 +size 13236 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cbf2be15a7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6735282469e15d3cef39ea05e5a3f3fbb6fb3fa6ef28c587db2769f8a9231d9b +size 13772 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6bfb7eeabe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c978bc799ab79290f89568de692d3ace219a4192ec9706fef63fe977a853f74d +size 6046 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7f0b79719b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.components_null_DefaultGroup_RoomSummaryRowLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4010b70ade44c2b3bf8296a87ae91c63653549d3c01e91a0c340968c7b66c1c +size 22501 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_ContentToPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_ContentToPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ceddac53d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_ContentToPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfa37b72fdc9ea8ed98954ab63ac44ca537703079b66c63cbc19d88912f3f5b8 +size 30471 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..593deb695c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:310f8bc43c0c3daad149793e2203f82acf2cd46e77fe3aee77c7661529c167d0 +size 29645 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ceddac53d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl.search_null_DefaultGroup_RoomListSearchResultContentLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfa37b72fdc9ea8ed98954ab63ac44ca537703079b66c63cbc19d88912f3f5b8 +size 30471 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a4bd6a2097 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d471d0f1456c1af6d00399be5acbc143607173006c679c840279dbdbc610176c +size 6033 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fb4ec656fd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbadc10af601d45c5a026903aeefbc65e6094a61dbe630da1a8418b2e5615ec3 +size 6422 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b8003b9fdf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf1bc7a8214c39b3f7727f0c0ce2c5fe2efb59b4de589dc9c0e62a404626e10f +size 5941 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..26f8fe8e8d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_InvitesEntryPointViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ee79f501000b94a5620563a5621b193613a723b1784ea4f65c2870ccd834b7f +size 6368 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c795893379 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0865467d3322b70d2bdabc4d95e8c78666cbf0d5e4ce3c0eedbf10da76a5f619 +size 12335 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..09990f7d91 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListModalBottomSheetContentLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b1de0249d12e4911c37251d4782a40c9344bbec6be3447e11ef12192ddbcdfd +size 12207 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ed2e84c824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6add27566f74de483cad39fe24dc878d3f1b1704ac7341b7972430ea57ca0c5e +size 35547 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..37560a5913 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0334a218c09298714de3ad44fbc2dc310efe542cc21609902d962a5059194cec +size 58860 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ed2e84c824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6add27566f74de483cad39fe24dc878d3f1b1704ac7341b7972430ea57ca0c5e +size 35547 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6ef66d5ddf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8aa48154bf2cf3a6ec6da96e24a86c6a3f1e6eb11865c2301a4f2638e471bd34 +size 37408 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..414354fd1a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c09f1ce03f41afe290f292bd2edffff0da4e2009b39e344c893659092fee04f +size 36865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7563f26042 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8748a41e95b09ea7ebf6d7827bace41adc13d41729ba7304eac1d2a313a39d5b +size 37199 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5da66a5ec9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ac28972ef51cdaced878078d7f2e4ecbd7d5923a56b3ec9b07f5dd92997bc54 +size 4858 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..593deb695c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:310f8bc43c0c3daad149793e2203f82acf2cd46e77fe3aee77c7661529c167d0 +size 29645 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ed2e84c824 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6add27566f74de483cad39fe24dc878d3f1b1704ac7341b7972430ea57ca0c5e +size 35547 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd1ae1158a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425cba23a5d057ee135a4d57194e5bb48aa4a9551fcf29859b101f91a1a9e22a +size 38301 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f3c4d30238 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05d71406e279c54b23d9a3731b624d643ab820c3cee180536d1d4cbb50fa0d3f +size 62283 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd1ae1158a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425cba23a5d057ee135a4d57194e5bb48aa4a9551fcf29859b101f91a1a9e22a +size 38301 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..75268923f9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8f155381ccc3f8833b58e076e797034dfa528bd55bafd982c34fb997e4db491 +size 39830 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..20132dce33 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb7fc5c4304d152d8f828a8803f369c78f8e94e24ba2c6935a19618707261f05 +size 39521 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..55b8105a7c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:371fb8f470f44fe3e6471d22c348b195af64ccdceca4f8149e896c53a30ce1fc +size 39904 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b1f6332ebf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb67d4f855911385bbbe7a41a6f92bfa225dae3c048f0aea078320a6969a133d +size 4864 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ceddac53d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfa37b72fdc9ea8ed98954ab63ac44ca537703079b66c63cbc19d88912f3f5b8 +size 30471 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd1ae1158a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:425cba23a5d057ee135a4d57194e5bb48aa4a9551fcf29859b101f91a1a9e22a +size 38301 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..936fe4c686 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b17397fbe00c25f0ec2012c84daa0483d53a4618777444d4cfd180d50c3cfc1 +size 27247 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..48fb3d83f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19b093776d9b970034fa65e23ce529e2729aaeb90694589c939295e2d5f89420 +size 26318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0c3abd6102 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52854ce12dcc8304004a2b6ebc2def5513dc390483f59d51ca30e149ec12d8e2 +size 56374 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d9d8c00aa8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac9abae6c6ac9b3b5d774f091ec89a40131b81a408624957cb16e33586f464e0 +size 57241 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0ebaa9612b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:115e7b766c41009e2ab75d0fc5b44e967e9e53e141bf36003fa8dfc1993cff83 +size 30679 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c432ea640a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:408ee6e94c45930a4c4ad7413e65eaf314a848f6bea7f3740a49cc68a7bd6f88 +size 25834 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3bf8f2f81e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee6e86ac798549910a5350dd371219ad0e0a1616d3f616e31dc2c38482ac2453 +size 28724 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..976d41eb59 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6949280df334e4f21b0fec7a05ca64f13adb93b849d142d69402c2a27b83358 +size 27373 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..36a54d5785 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b8870f4e25d40bac82e6f252f74d12f89a00b89b5ede2cdbfc613fbb3a4bc8f +size 56838 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1c70233409 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01485b9e3264e64273d720474b947b3372b7b3368b8eb5b575d4ca1593e0eb9d +size 58058 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..06d46ba33b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe8368c7bc5a16f178c114391aa7a2f7801aeb4b73f8df0f2ad9bda1e690059b +size 32121 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..df4474d272 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.verifysession.impl_null_DefaultGroup_VerifySelfSessionViewLightPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f8fe0565a38d640227b5d4ffd552d7fba2c0fea7978a3d578c34b1dbc36ed9d +size 26670 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..820a5f9889 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c834401ae22ca7ceecd0d92cd0aadaed9e3375b384b295c1e0c4f59d3184a642 +size 51603 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..de08bb030c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomLargePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad7fd75f0b2bf8bb9c3c3b38e6a30822cb8732a80228040d3e5046851662ac9a +size 44271 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b89a9a7443 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc17f444d7141faa35cab1446cd02978916e80e6a82405cd96ddbd9f91ed24f8 +size 25327 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cc87d1064a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomMediumPreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d21f91a5f4ea3f441a9f26da601f65da8054f2b7a330b983b784e811d13ae589 +size 21692 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..15308b30bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51616fee6314d06981ce18d654c166d8e941be3264578c89e479a2a1267caa65 +size 19226 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3af060ee1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a7f455414ed06ec16785049bc3e99fa312a89599d24bcda0dc611c390e10c73 +size 18734 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3f9abf851d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62c4516b75a411a791d1626005d9991f5dd296a92434570216e317698f3c5984 +size 4893 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4a6a1e5748 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_PlaceholderAtomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87dfe98b60a28ca80529cd385ce4da8f90b2d31e612d964c94c8d66ee28e0749 +size 4918 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f5b63c5df8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27d75afaa03dd48f98429b44406b8ce348713e61b3ffb27b8f1d4db749fa0a0a +size 7276 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1102d9c1dd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_RoundedIconAtomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68474fa9f780a975199863d86adae64269257d1c29a0588ef860b371f8bc107f +size 6834 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..71244fe2ec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a26dd01a71a5fde9ae8d4518c8e8161f469b6145f60c10b9d10d5e5b6319f92 +size 4868 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f3c025ea64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_UnreadIndicatorAtomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b66d08345251e88e14fef4dce9188cd29635d712471aa0db7230b95a4662cb7f +size 4882 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..45788b5180 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fbd442e62fc2c8e02040d0ef42be06af5a1d554ee386e983c4bccae199004aa +size 9456 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f02f83b162 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonColumnMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f9e941ab8eceefdd858c81667a5bafa33a4efc7d232ad0d501547ecda37758d +size 9616 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dfdb106006 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f35e88e2b096b989eb669b8fee4196025177b29a9c9b729b843dcf7978df6add +size 7420 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0cff850500 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_ButtonRowMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60c7dc93e9f13f1452473f783e7ecf7fc776610108a0a5e8fdf92c9e4f93a6a3 +size 7528 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..eec52daa70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0817c0d8b1f47e626b2c07e572d1ad63b201086c7b39e9911cc5545760f5f466 +size 10271 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fc2b5bbd53 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.molecules_null_DefaultGroup_IconTitleSubtitleMoleculeLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9eef98b051847520fb6512e7cb22459b1efb0a7884443cc3a83938a8ada5488 +size 9792 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..897726ece5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb3c0bfab35430ed53bf55fa1f620ad8c478d7461bc1c315de28c3e094730c0f +size 13922 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..373e0312d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_HeaderFooterPageLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51a7f1f0206994de74bfb04375204ff017507c6fd7ed0e359906ce19f0b8b628 +size 14900 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9e191589cc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65b33cc70bdfd18f94ea77fe39e6bb6e0595b7fe68583ed88c891baf0b907743 +size 295653 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b25a0b30a6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.pages_null_DefaultGroup_OnBoardingPagePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:709d08d911e60ef3ec443a347a2824ef42fccc15749903ed3492fa980869c505 +size 430087 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c84d42e293 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12075b74ecb0d505b38c99bac410d4195ed85e6cf8efd2ac07848962b7970238 +size 10972 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..926dab7b5b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncFailurePreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7e2f73cd2a33a946b256dffb97ff4485a17040a2f7358a4773bfe028ccd9e4c +size 11042 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c8617fd4e6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d59a59a73eae2ace63780b32e49ef940e69bf7e6f3bd4e9cc99804f2b86d315 +size 6581 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..17528ba176 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.async_null_DefaultGroup_AsyncLoadingPreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fa083fbb8164c6c4f4f4e55be3744ee04b0197a88982f9edac37b03f2f9e9ad +size 6270 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..33bd0a6514 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dfd0364d23133289d2a3ad3a998769a0722f3bf13e68c832868f97677563dd4 +size 17935 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9dc875f845 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a79e2eb5f136dce29acaeb29722367ca0903ba2441cd1eca5dcc7e01636936aa +size 17347 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_10,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_10,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f9bdce6450 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_10,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed68dfc5ffc110e0794ec578d61a45386f76943110aa41819a2dd9fad4d90bbf +size 19167 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a79096e42 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8b8ebeaa6244f88dec707d00cab647d3205ecd1a031a269e5a26ce5253685bd +size 22019 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_12,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_12,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3b8b251633 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_12,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31dd10477b9c3a6a522464c01ac115d67d09e788dd3ff90dee23bb29a742dbb5 +size 20139 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_13,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_13,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e2d548a72 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_13,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:459fbb9864d1a1d8f3a54a060d60972d0fe4fdf8391381d400970756c762d258 +size 18895 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_14,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_14,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c781dc7be6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_14,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baddb6d4fd81c775d73e87884945654116d9b6f98cf62fc82597b40ebff731b3 +size 23211 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_15,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_15,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..79fe4747d1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_15,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba65d101418a90fadd97e295e1d8c909e02954f748674e3e8c833ed54227b4cd +size 22996 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_16,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_16,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c71e06b46e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_16,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:938a9c25649780c7581c6733dd9e6913f56eef96975ac29e9abab5bc7af0c8ab +size 20984 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_17,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_17,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b1d65a8e09 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_17,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0056424b9ab13e9a3eaef918f7b085d806bf8c8e9431d0b9341e3a5cd28e4940 +size 27640 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_18,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_18,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..41dec8e7d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_18,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de3be777cf2256575c2d360587652f61f889b8e3771e4edde40851333bd64685 +size 16716 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_19,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_19,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e38dc818a4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_19,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb08ee71c6566073d63a7741605b61594694829888dbc583f73aeba10122fd2b +size 15915 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ddaf0c6410 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d0f81cedda5b438d1d093167ce4cbd0bcb13c89d17511d86ba7a6e10173b894 +size 19546 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_20,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_20,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f56f058f61 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_20,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0816e4338e6f5b8dd1c36c32676dcddea3109525afd086107eebbf40f7cc2260 +size 18871 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_21,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_21,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..92c4961e37 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_21,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:196f4bf95edf2183623dd4828509b9fe0e67f9ed4c908c0dd7f187d9ee80e7f8 +size 19437 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_22,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_22,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0dadc862bb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_22,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4b7168caa7359692f9f2fd6dc1f92d855aaf0c27fcb5ef00c5e676ea2c3308f +size 18171 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_23,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_23,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c0365c47ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_23,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae0924117cec1a4dadca4c4891517e0e258897033db90ce2ac27359ade331c17 +size 22514 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_24,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_24,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..99913e6f44 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_24,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a44e1a6553d5d30c4d8767b9d7e397ce2502c0ec3a6ceab7372933f535baea9f +size 20316 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_25,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_25,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..50cf9888ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_25,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f13e2fbdcd77586ef4078d105e74d753ea8adc2f14640338059bf2864525e0eb +size 19063 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_26,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_26,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1e0ca6d3ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_26,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6784742c9249d6bd70e15e795b193bd66ae3c5e8a796ddbc335560a7430f2f9e +size 23349 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_27,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_27,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..41015fb77c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_27,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:307867952ce3a6baae666e0d8bc904bd1efddde2b60fbaafe9f41e4d35f632aa +size 16297 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_28,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_28,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c96fd7f4c3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_28,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00ed2d7954e7866191750475b9c496477e7144998c2d630e9e8cf47fceaa72f7 +size 15599 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_29,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_29,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..497d76d07f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_29,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85b4e6230abdd27fc958a80c35f2d4d62953ffe5f868f561b02504050f6b1d44 +size 18168 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_3,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9ba77ed8bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_3,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aaf9688bfaa27710c88cf1f6c137f5dca640cf86a7d7419be0d647042ff3122a +size 23369 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_30,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_30,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7eae7c7bab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_30,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:901a4a4903e9dafa3f4f4741ec0b59a8901326cc4f203bd8207886965c63d3d5 +size 17159 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_31,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_31,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7fbf48fc4e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_31,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d0f97c11c56b47e0ae5b8e9cdc903a94a062a7fda9ab31d6dccfbb8507dee42 +size 16498 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_32,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_32,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..66e958128f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_32,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f70d0db3607bc2ac1385acc67bac28d245f9638581734770b2f43b63662cc37a +size 19000 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_33,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_33,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a14694866f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_33,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1791957ceafcdad1ca80f03b8ac077b74219e0655240ac0f7e8fefeb41f9ef5b +size 20867 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_34,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_34,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2b01d7d195 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_34,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09dfb9fceb60f2f6fa1b12c7e010a25e5eacd0d9116e828bcbba1ca01be366d0 +size 20224 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_35,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_35,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..07cb21efeb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_35,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0831ab453c142047b3bac65dcb243b6a6d2079f0fcf8c8a0f3d801c8ccb450e6 +size 22610 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_36,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_36,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..422a0268a7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_36,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52b756870c076794cca7eb0f8fbe591753135effdcc8ce810a2ce0d56a32dde5 +size 18886 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_37,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_37,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c89c036413 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_37,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3058026a29a814ccba97a582502cf499a8a6f36c1c3166ef1a845218317d79e +size 17744 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_38,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_38,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7f70068f4a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_38,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:192fc7e3475f8e787c860ae74f60562eecf36ab4322a6c54d0a105f648607188 +size 21850 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_39,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_39,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7df97fc06d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_39,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6da9570ae74b2b7de831c559560a9c9640d187b5d22ef7b5705fafa1d488273 +size 14613 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..451a7e50ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dca040b73eb8e1bf4d5355b16b162093e79957cfc3abe6e08c90948595bcc3c9 +size 21387 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_40,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_40,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1aafa8431a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_40,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c8445fab89a15fb91a32b2d11d87d745d68245aa76ed631e16eba5ba79105b0 +size 14315 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_41,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_41,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5fa4d953d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_41,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5abcdf7a9ae3dd8c6ca33455e7b6d0446b20b0e4e04393ed45bfc4ad615a2ac +size 15454 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f8e861ba63 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:459bdb51bfaf035e79f02c50c86c3fe5e36170dd454578f61bbcceac1e7ce955 +size 28168 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0a4aca79a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:644dd697fd4a2cef4b1804a661a453e7c43f833f332e198512c26816c265eab9 +size 18673 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_7,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_7,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cf9b254197 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_7,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9d927c84ac2568b79e79a9a32d969b0f48bfc1dfc5217d6ef440d596150bc2e +size 17534 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_8,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_8,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..da7e547315 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_8,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ec1cdc126c00ff5dc4e64d3b6937a6fa991968dc936c578275eb4005d762e52 +size 21688 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_9,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_9,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3e1f12e4b6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.avatar_null_Avatars_AvatarPreview_0_null_9,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41f8b1a8d1d411c2910d005429d4d0efc8cefae7a3ea06943cd4cfdb62160933 +size 19960 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_BackButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_BackButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3713f657e8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_BackButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0186b4f2d64220e1b0341f5007f0577d82a0c0424ad53937b6669e772fbecc4d +size 7711 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_ButtonWithProgressPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_ButtonWithProgressPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..31e652eda3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_ButtonWithProgressPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8b30dd030cae4bd394796da45e3e067c7aca5fb5176bdd73a8a24938f8571a1 +size 17707 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_MainActionButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_MainActionButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2a8be26d5e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.button_null_Buttons_MainActionButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73345f699b0b56d6a2b3dd2f9cc2dc91473ca611a563e12bdef9b55066418c25 +size 14351 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..178db99946 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a66a098b4d029369637ffaf9d5ac24f7a76d168b526382c78167132756476c2c +size 22945 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7376ab8e10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ConfirmationDialogPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a32ebcf17a8906570bc08df47cccac1ff4b5c7f7cd0bc414a188c9e302f6934 +size 22931 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ErrorDialogPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ErrorDialogPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4e240079ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_ErrorDialogPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c0be8e52cbd3d139601bcfe093174fa5d873f2433709a1306645062c9a8bfb9 +size 17921 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_RetryDialogPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_RetryDialogPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ecace25254 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.dialogs_null_Dialogs_RetryDialogPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73c68fc2181960285410a51007a033b461abbd9ad89844d18eae10dcda26bdd4 +size 21302 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..56b3605183 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c37f4429bbe904122e409e614b09396cf493fed5ce274961918d68b00c9faf0 +size 5891 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3c228b96a0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences.components_null_Preferences_PreferenceIconPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2d302d58c1faec3c3f0aa3286977b2698cd642c1e6dbcb9632510c6a4a922eb +size 4501 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..552fc9946b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1911edc801b932f90b0778e01d8c3b75399bb28591fd6cd7aec02b369a0f06da +size 25257 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..eebbabb4b5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_DefaultGroup_PreferenceViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c1109cc7737310e469ee0e68343dd8d56c9b94858b6f4bcece7c58d77b64aa4 +size 27195 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCategoryPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCategoryPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dd0394e3d3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCategoryPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c78c08d1f40ec40bd40baea56772f55e000f18a5c451eddf289c16be7efcc8e3 +size 28977 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCheckboxPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCheckboxPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..06196248f4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceCheckboxPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06813451633c48fccac6f70810ecb9465946b643376fccd61312b6064b0a3a40 +size 11518 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceDividerPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceDividerPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9db1d68036 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceDividerPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:02e28dd43335ab8b7aed7219bda0d4009b2795df7858fc85737ab2915482adea +size 4568 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceRowPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceRowPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2cc5845635 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceRowPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f89ba9d9312cde0f01e31bb09e2e6f3808d0721cb6bcaa78f3482aa5fe1b5d78 +size 8515 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSlidePreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSlidePreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e70238c94c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSlidePreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95e3e77ac13c2384236b56f7ed0f05a5e00ab3677531782cb82a2f9c90cacef4 +size 13946 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSwitchPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSwitchPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e1fcab2e5f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceSwitchPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60074c3d771118950867747a1696e29b9113c2efa7a08895b4aabc747148ba57 +size 17642 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4eae75f39b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components.preferences_null_Preferences_PreferenceTextPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f61dc1d9cbb09d39e229535520ec49052d3a12626a7b1a909031189654e060a0 +size 39162 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2933832cbe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db34c9789cc413510a032913fff3acb6b5e496c1c760bd7f9f4bf10c29a45857 +size 15118 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..aca49e157d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f35c9b7e7eb8a01abb72731e1198ebeb8c24c96a128922a6b55c3e05f3397fe6 +size 15595 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cf640a5e80 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d08207b517c75145a791e0d1872bb65eb24cfd8c5c9364fb620e392ed9025c51 +size 5678 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..47b0f72940 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_DefaultGroup_PinIconPreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51e6c82dee78de8f5f320c30625ca7ac01918042cd13dc71611087e422e9ab12 +size 5637 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Dialogs_ProgressDialogPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Dialogs_ProgressDialogPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9d7ab1352b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Dialogs_ProgressDialogPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22747c979bf704a554dfa4ee3ace1551e78f1180bc594c55f1497ec1d6529aa2 +size 21211 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Text_ClickableLinkTextPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Text_ClickableLinkTextPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1bbfd06116 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Text_ClickableLinkTextPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b9609534a6f984fa7045eeb974246017b692ad57afaa632e98f0a4b7cf3d8cd +size 6896 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Toggles_LabelledCheckboxPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Toggles_LabelledCheckboxPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..25531969d7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.components_null_Toggles_LabelledCheckboxPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b20095a65f8e58eeeccb24cf84918ed7e98ec868f610f5fd98702538fa690d +size 10685 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..509177dc0e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fcd168ebf9e2aea118ce6af097284d9eaeb3992f8563ac7c6e03209af35bb1f0 +size 6920 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cc7b5c4bec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_HorizontalRulerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9164b595b98bab6790e7dba4753d07511d269da7c35349be4327de9583fe8041 +size 6341 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5414e96ac6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fcad6b130167d4266509ecc2b60b0400cea48533099afd987dd08478a88a18c +size 9464 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..07e35681d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_VerticalRulerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a193b215573ef4e4b25a13a65c39511bc52a9c3f3645fe46141adb6f8e0e153 +size 8023 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..767844a2e8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9dadf14d9538191b4f9be41d8deb4e441008d80eb687d3f01484d6e497dcadb +size 14291 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd36276b8a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.ruler_null_DefaultGroup_WithRulerLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16f731c6c1d2cb9b462b22ab56475fda53844747eb0c587d801456903c3d7bcf +size 13697 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..22efc21bb1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c539176a004d4664f8f0eba24cdc00950b42284dcab249ef3bc87b0347df85d +size 32949 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c1cdbcc5b5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_DatePickerPreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee41ce428593fc4d8f329e623314c2200a55cb99950e6896f8df17bef5f7f3a1 +size 34187 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerHorizontalPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerHorizontalPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5fbf7f31c1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerHorizontalPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:899b8d8bcb319ccf50867ce0e6f1333eddbcb232427098ea15fcacd37bdd6f43 +size 35981 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..51138d72e5 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab443d66a938f968fab3ce8c26d7b567dab72ecc3c505ab1fa9f87c30822057c +size 25451 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e17d053854 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_DateTimepickers_TimePickerVerticalPreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c4db502fdc68ba5c1d391bc7b14b9a5c852154f0c1ee48788ebfddaa8bac21e +size 26441 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Menus_MenuPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Menus_MenuPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8d195dfdef --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Menus_MenuPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dc928db8fab90762a934c10dfc8d50aa0ed7ebe776bd2705c4f0cba2eac74fb +size 11262 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Toggles_SwitchPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Toggles_SwitchPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4cd2f5661a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components.previews_null_Toggles_SwitchPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:265ddc2f480f343efbb8717a66f5f933714e5320e837f0d26d825ed595612f2d +size 21039 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_MediumTopAppBarPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_MediumTopAppBarPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3c6579753b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_MediumTopAppBarPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7f0987b11f1ecc5e8359b668ac8cf29f0d5b6ef6f5c74d3660e68696be699ae +size 7219 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_TopAppBarPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_TopAppBarPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a6ece5f74f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_AppBars_TopAppBarPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f470f47f972d0c948a308622e49855bb88365675d8b251cd1cce9972f5569c9 +size 6878 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1256174d44 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bec5dbe16068cc5ca6e8b9e561dc907e5c4a4ca52380d9a9db90d26fc5a8ff7 +size 8349 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..03e508af2c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:683a5c93ab69811f175794b1bfba9895965dba6c9d28939c155c0bce5bfa7b56 +size 10879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f12c649c75 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLayoutLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cc78091ba105bede8c66461228c5259689f84ff42cebc1764bfe92e1f0be351 +size 10679 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0d55929086 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_BottomSheets_ModalBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6b5d5f0e520e27914965fd64d674478d63c579c616607a38ecdf614b828bca2 +size 9041 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_ButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_ButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..53e4ece077 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_ButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ffe5901b54ac442c8b71a7ff1d59bb3903ef2374c6fad378e55fa857e2ff34c +size 23162 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_IconButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_IconButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f6c9fc64dc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_IconButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19db35d98bc8f6e4525e297616b564c9c2acbc2e9f7422292aca4a5d485c4314 +size 7682 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_OutlinedButtonsPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_OutlinedButtonsPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f918f32903 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_OutlinedButtonsPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6efea5a0c9cd8e5626c16248e020b88a069ce878b4471e93dd9ccb956ddd41df +size 26956 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_TextButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_TextButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ebcd57bd3b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Buttons_TextButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:640035df43196f84a7031578feb4b54f9702801c7974fc7af6a80eb907c40097 +size 17486 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_SurfacePreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_SurfacePreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c46fc302be --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_SurfacePreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07b197949859faccd8fb744a65b2a354f372d333138cf36b007fc6a47e301662 +size 5268 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Dividers_DividerPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Dividers_DividerPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..390318f1a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Dividers_DividerPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83ddc3b82da2c69c09178c63d936fc10ae56f29303641413b1d31b7448155f43 +size 4708 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_FloatingActionButtons_FloatingActionButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_FloatingActionButtons_FloatingActionButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9d9156d829 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_FloatingActionButtons_FloatingActionButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5931cf1a6098e1b3011d81e544b65b6612a603ec3f5fc46de55b73a115ed5f8 +size 9692 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Icons_IconImageVectorPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Icons_IconImageVectorPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ab2504faaa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Icons_IconImageVectorPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d18194db4723ff7442839e45aaca9562a135ef31ab840ad0aead84af15fa8bc +size 5817 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Menus_DropdownMenuItemPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Menus_DropdownMenuItemPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4c91805426 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Menus_DropdownMenuItemPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1765f4842cbd6e3c1b7daa9a9cc13fc0f4c47ff73c2cb1fdd7f3d2377eea0553 +size 9053 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_ProgressIndicators_CircularProgressIndicatorPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_ProgressIndicators_CircularProgressIndicatorPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6e4953e31b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_ProgressIndicators_CircularProgressIndicatorPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbbfb5dcd90c9866fa6cb8a16dbbedae73c88b7312226701102d22334d0c2881 +size 13152 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveEmptyQuery_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveEmptyQuery_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..47cdb142a9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveEmptyQuery_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:414d0aa4f268cc02b0e8c6e45fff5c5fdb7ff56861fd3eeee4406cd91e75eccb +size 8404 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithContent_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithContent_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9d62d76c70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithContent_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5394502f73d2cf76dab8fdea9492f1085d58cd8b1793b4608fc58bc107bf5ed5 +size 25620 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithNoResults_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithNoResults_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..799153bac0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithNoResults_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e117d09443f244cccd5f8e89068ce69a76fd0d38fd4592e26d524c82421a407 +size 10277 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQueryNoBackButton_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQueryNoBackButton_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..35b4f68bb7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQueryNoBackButton_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb44879335772261adf4c636f7a70c023ffe3a8e15965dbff6267a18595e42ac +size 7868 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQuery_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQuery_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b83bb628fe --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewActiveWithQuery_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4080aaa69bb4112f30eb01c41822d5f777eaf65b9c6f0165046c36c8dbd809f +size 8154 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewInactive_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewInactive_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..62dc9f31e0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Searchviews_SearchBarPreviewInactive_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95ab3510171f4b1bf786a02ed3dce2510185952880117ef2c3945f5cde9e9719 +size 15235 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Sliders_SlidersPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Sliders_SlidersPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..95dab8ba6a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Sliders_SlidersPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f927966552d2cfcda554de5abc33227fa2cafa2573ddfd8627bab403fdbb99c +size 11305 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..5cc600c963 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32c39b1972cc349722095f7932f04b424aa7334f965cabd1ac2314b9199a8e5e +size 37964 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..959594bd99 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_OutlinedTextFieldsPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0c0d81e9bb974158503f65fcdb71d2679d3023f28ab43ed02646d4ef0b9d657 +size 39359 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4d112a2246 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e36186de5f52bca61387cd793e78c3fce76f69b7c8a7203689015aec6bb99c +size 36896 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6e2877b309 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03ba2f702d9602a670d68ca1453f8e53df19972756a778bd480c127245408c03 +size 36850 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3ff3881402 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a12d6d6f841dff1bceeaa742ba3dd9a6178cd82af0cf8d934477fdeeb775a11a +size 100768 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..eccb2cb589 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Text_TextLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aeef3bbcb238e483a1a426ce7abf0bbd04aade4e37ebdd5a7e8fbd032e31ab57 +size 101865 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_CheckboxesPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_CheckboxesPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..84068bb6f7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_CheckboxesPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb51ba92db07682afe5727078f8e8b0a44cad16566b97430ca92132689bf25e7 +size 10188 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_RadioButtonPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_RadioButtonPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b7dfb49de8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_Toggles_RadioButtonPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae7c673ab96eaa30a72ffa4b960b93f7c5971be1155aba7d03ac4c43652098ca +size 16361 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a22f12ccfc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13dbbde608d4a8dd2a49e0b8a04fc0e39b87cdea3298d3c46e810fe8592a1426 +size 42031 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d74f8451b4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme_null_DefaultGroup_ColorAliasesLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea0be9156dc4af3feb232c764537ab5a12a6a7d520a192605bc654ce68642687 +size 41546 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b6ba6ad76f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4403fa880ee07385cb875e254db08585d130191920ca731d85d532a2860e3f54 +size 13238 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..26f8c77951 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_AvatarActionBottomSheetLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d74afa7517e1bd9249792e4373a1bb8e738978cb522f7128b96f00aea64d24f9 +size 15072 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7bdf26fe6c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:254850b3a638c4d54d9f6642c15caf3e4037c47ce50ae48102e2346df6e8947f +size 29282 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8bdc8eb362 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8f5077f75ee156faae31e73d464840fda51f82b0666f0a8e661295eb193b133 +size 27442 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cb3c20fb27 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f59b518dafea7d576a888661d740afeb1a6e72d432527a5f27d4a0a695eab35 +size 29718 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..26e1ff331d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableMatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7988848232d0f44f6fd04b1a8a8ce17681659be7e63a9f68f4f3743d51f3a679 +size 29382 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableUnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableUnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1ab026adf0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_CheckableUnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f6792f7963608fd167889be3e657ac6ca6eafa6949bfbf03fa21f9a07b34573 +size 115873 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cbd1ab9ec4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79de0ef9c06b6e95d02f6a3dc27d10d741623de162a5597822a50692f2cd28a8 +size 13089 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1d8ad5d01f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d38b0802eab518e2546629197b418d7a1b0369d921b199dc59797bae0636ed94 +size 12435 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e47261e624 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a56c4aa5117ec8ce4d63ee679443b88eaddf84795b21f4b61429ae10ddfd2fe +size 12831 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e8c8e656eb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3b82fb2d33d5abf66035fd26b7eab778f4b6aa67f29b05e0b252a9cb57ce365 +size 12957 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..23cf3ada3e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd987ad48b569d423d1c37743d0e0b862600cf166f357c997435bad6841e3d4d +size 6001 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c1d278b09b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserHeaderPlaceholderLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61a986107eceb0f6eb11b0807946a84a0c61887fc5c1f5232c70e180c2f124c5 +size 5793 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..7e3f2ed3b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edf4c448ca507b4e2c498350b168a7f42f08512e833fd57d887ec903eafc0f5e +size 11152 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6d93934e0b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b5063c1f908150d6b5f01e582f771d760721e0bffd217861657e01630521d89 +size 10523 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1a5a17f3dc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9781e931d4e1223fbe2275d633279b713a0f7b1f7ceba6de5c35765822dca6e +size 10907 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b7b7e3c849 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_MatrixUserRowLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe99c59f68245be4ebb7c0c31d59c6c7f55d00d175e99beadc30122e903ab2d1 +size 10893 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a9d1f9ad37 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:300578a272b27a4ab8fc6711be0abfc3c177e5e0dd997b5f97a98e9fe9c74f3a +size 9310 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..913bb21f83 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedRoomLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f7a2cc6699d733bb50fc7c63cfbed0bb95f96f0330ce739d0fce2ba04bf10cc +size 9094 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..058574acc2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdb02351d5629ff54b2dbcbec07d10a49c27b9e37380970c61ef84821fd25395 +size 9509 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ade22a157a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUserLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:108fb128e090b3419039289fc70cebe99650dd1bd207dba43bf3f20e9835c14a +size 9175 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cd320ab946 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:249964f2ff47dbb8b7789c603712315c991e9c256f0dee1c109e9d822bdc1e0c +size 75160 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a8a521764c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_SelectedUsersListLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c875f9f0f7178de948fd4f4e45f5f97aa8d176bf958a2766815e445bfcddc6a3 +size 74334 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3d321e139c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnresolvedUserRowPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da8ded38599d8b34c458ac5ce90e52f5206617cc9f341b2e900a969fd298e03b +size 32409 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f2e27c2b0f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0848d17e3eeb4a6981319bd06f0692118698e05174bde820a09cface7820e1c +size 37868 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3a63810f93 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.matrix.ui.components_null_DefaultGroup_UnsavedAvatarLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:477627a033f689d830a34a395bd7d7079d3891cc1d44319105f28ddc20bd7af8 +size 37857 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0ce9bb484a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:583910d40d3832aec4f2f1970f203e208ff6c8d4ea4a77fbbb3012a16d74b1d7 +size 23588 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4f1e3774a2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eece69f07544c8e61080cdc4391a12bbb52b30759a330a0967678af73112fda5 +size 32910 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e1933a744c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a5a75e7614025e08e320771628ddb7e3b24f055bc5ced0785b9b259f183200c +size 27249 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..38772ec2da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8cb222bf65bcc884814d8af92f032a79a2bdbf6f66f5ca792e7f72ad3917fbe +size 24442 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0f492d482 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a277bf8619ef6f8d52a83d33e855f03e28dfea14e2af2482db3f7ce1a89c35c4 +size 34396 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..94861e6fda --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d61c7c8f802a9f1bfefbf4d2b0e666a9e2162fa13bab523584baedf380c1df6 +size 28484 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-D-1_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-D-1_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ca2f7fa2c1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-D-1_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d894961edd66976876fca0d78161b14b5e4c578a65c57a77883fe67f28c99597 +size 13217 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-N-1_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-N-1_3_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..785d25bc03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerEditPreview-N-1_3_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:369b4c33e93d5e283075cbf00dfaa10e1e40397ddc2d9d4012d2d49374b1afba +size 12532 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-D-2_3_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-D-2_3_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ae71bba7c6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-D-2_3_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d27727e317ba01c96bf4940075728e3538c31fd5af51f9d9bf931b6d23760ac8 +size 80889 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-N-2_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-N-2_4_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..75d5c026c7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerReplyPreview-N-2_4_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:95634740842971ff29c2fec9ee578dfc04bf0e61ed90273b9ae20c7b3d9fdca4 +size 77969 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..49a049d0b0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6147159bf37f84a4e282dd701f9eaa607675639f512b6172a4ab17cdbbbca531 +size 34879 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1efdfbd98a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.textcomposer_null_DefaultGroup_TextComposerSimplePreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f144dcad05008b759d2457fe8cef66147a7d73b26cf2fff51286e306bcabf3e +size 33397 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewDark_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewDark_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..761cef5007 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewDark_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1812084484f25feaab09dd645646da437ca91203cb35112478b54f85ad63aeda +size 118789 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewLight_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewLight_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b29e50390 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.theme_null_DefaultGroup_ColorsSchemePreviewLight_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70d8bbc2aa2ed05d5c2ea807b93022a8b46e6221073ab41e769fb6263cd2a31b +size 115870 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..70da769e1f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:484c56c1ab68d394200202bda364175a1fbd27df31630649010852a4c2552870 +size 20801 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..f2a152da09 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.services.apperror.impl_null_DefaultGroup_AppErrorViewLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8d6b5bcaa10f4ae2142edfef6745e22e0e9f8f52d9fedcbe77d3523ec4cb48d +size 21476 diff --git a/tools/adb/deeplink.sh b/tools/adb/deeplink.sh new file mode 100755 index 0000000000..5d50ec9409 --- /dev/null +++ b/tools/adb/deeplink.sh @@ -0,0 +1,28 @@ +#! /bin/bash +# +# Copyright (c) 2023 New Vector Ltd +# +# 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. +# + +# Format is: +# elementx://open/{sessionId} to open a session +# elementx://open/{sessionId}/{roomId} to open a room +# elementx://open/{sessionId}/{roomId}/{eventId} to open a thread + +# Open a session +# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org +# Open a room +adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org +# Open a thread +# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId diff --git a/tools/adb/oidc.sh b/tools/adb/oidc.sh new file mode 100755 index 0000000000..bcc519f313 --- /dev/null +++ b/tools/adb/oidc.sh @@ -0,0 +1,24 @@ +#! /bin/bash +# +# Copyright (c) 2023 New Vector Ltd +# +# 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. +# + +# Format is: + +# Error +# adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?error=access_denied\\&state=IFF1UETGye2ZA8pO" + +# Success +adb shell am start -a android.intent.action.VIEW -d "io.element:/callback?state=IFF1UETGye2ZA8pO\\&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" diff --git a/tools/check/check_code_quality.sh b/tools/check/check_code_quality.sh new file mode 100755 index 0000000000..9e8c964499 --- /dev/null +++ b/tools/check/check_code_quality.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# +# Copyright 2023 New Vector Ltd +# +# 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. +# + +####################################################################################################################### +# Search forbidden pattern +####################################################################################################################### + +searchForbiddenStringsScript=./tmp/search_forbidden_strings.pl + +if [[ -f ${searchForbiddenStringsScript} ]]; then + echo "${searchForbiddenStringsScript} already there" +else + mkdir tmp + echo "Get the script" + wget https://raw.githubusercontent.com/matrix-org/matrix-dev-tools/develop/bin/search_forbidden_strings.pl -O ${searchForbiddenStringsScript} +fi + +if [[ -x ${searchForbiddenStringsScript} ]]; then + echo "${searchForbiddenStringsScript} is already executable" +else + echo "Make the script executable" + chmod u+x ${searchForbiddenStringsScript} +fi + +echo +echo "Search for forbidden patterns in code..." + +# list all Kotlin folders of the project. +allKotlinDirs=`find . -type d |grep -v build |grep -v \.git |grep -v \.gradle |grep kotlin$` + +${searchForbiddenStringsScript} ./tools/check/forbidden_strings_in_code.txt $allKotlinDirs + +resultForbiddenStringInCode=$? + +if [[ ${resultForbiddenStringInCode} -eq 0 ]]; then + echo "MAIN OK" +else + echo "❌ MAIN ERROR" + exit 1 +fi diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt new file mode 100755 index 0000000000..17f352fca4 --- /dev/null +++ b/tools/check/forbidden_strings_in_code.txt @@ -0,0 +1,134 @@ +# +# Copyright 2023 New Vector Ltd +# +# 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. +# + +# This file list String which are not allowed in source code. +# Use Perl regex to write forbidden strings +# Note: line cannot start with a space. Use \s instead. +# It is possible to specify an authorized number of occurrence with === suffix. Default is 0 +# Example: +# AuthorizedStringThreeTimes===3 + +# Extension:kt + +### No import static: use full class name +import static + +### Rubbish from merge. Please delete those lines (sometimes in comment) +<<<<<<< +>>>>>>> + +### carry return before "}". Please remove empty lines. +\n\s*\n\s*\} + +### typo detected. +formated +abtract +Succes[^s] +succes[^s] + +### Use int instead of Integer +protected Integer + +### Use the interface declaration. Example: use type "Map" instead of type "HashMap" to declare variable or parameter. For Kotlin, use mapOf, setOf, ... +(private|public|protected| ) (static )?(final )?(HashMap|HashSet|ArrayList)< + +### Use int instead of short +Short\.parseShort +\(short\) +private short +final short + +### Line length is limited to 160 chars. Please split long lines +#[^─]{161} + +### "DO NOT COMMIT" has been committed +DO NOT COMMIT + +### invalid formatting +\s{8}/\*\n \* +# Now checked by ktlint +# [^\w]if\( +# while\( +# for\( + +# Add space after // +# DISABLED To re-enable when code will be formatted globally +#^\s*//[^\s] + +### invalid formatting (too many space char) +^ /\* + +### unnecessary parenthesis around numbers, example: " (0)" + \(\d+\)[^"] + +### import the package, do not use long class name with package +android\.os\.Build\. + +### Tab char is forbidden. Use only spaces +\t + +# Empty lines and trailing space +# DISABLED To re-enable when code will be formatted globally +#[ ]$ + +### Deprecated, use retrofit2.HttpException +import retrofit2\.adapter\.rxjava\.HttpException + +### This is generally not necessary, no need to reset the padding if there is no drawable +setCompoundDrawablePadding\(0\) + +# Change thread with Rx +# DISABLED +#runOnUiThread + +### Bad formatting of chain (missing new line) +\w\.flatMap\( + +### In Kotlin, Void has to be null safe, i.e. use 'Void?' instead of 'Void' +\: Void\) + +### Kotlin conversion tools introduce this, but is can be replace by trim() +trim \{ it \<\= \' \' \} + +### Put the operator at the beginning of next line + ==$ + +### Use JsonUtils.getBasicGson() +new Gson\(\) + +### Use TextUtils.formatFileSize +Formatter\.formatFileSize===1 + +### Use TextUtils.formatFileSize with short format param to true +Formatter\.formatShortFileSize===1 + +### Use `Context#getSystemService` extension function provided by `core-ktx` +getSystemService\(Context + +### Use DefaultSharedPreferences.getInstance() instead for better performance +PreferenceManager\.getDefaultSharedPreferences==2 + +### Use the Clock interface, or use `measureTimeMillis` +System\.currentTimeMillis\(\)===1 + +### Remove extra space between the name and the description +\* @\w+ \w+ + + +### Suspicious String template. Please check that the string template will behave as expected, i.e. the class field and not the whole object will be used. For instance `Timber.d("$event.type")` is not correct, you should write `Timber.d("${event.type}")`. In the former the whole event content will be logged, since it's a data class. If this is expected (i.e. to fix false positive), please add explicit curly braces (`{` and `}`) around the variable, for instance `"elementLogs.${i}.txt"` +\$[a-zA-Z_]\w*\??\.[a-zA-Z_] + +### Use `import io.element.android.libraries.ui.strings.CommonStrings` then `CommonStrings.<stringKey>` instead +import io\.element\.android\.libraries\.ui\.strings\.R diff --git a/tools/danger/dangerfile-lint.js b/tools/danger/dangerfile-lint.js new file mode 100644 index 0000000000..8d704ef5a8 --- /dev/null +++ b/tools/danger/dangerfile-lint.js @@ -0,0 +1,34 @@ +import { schedule } from 'danger' + +/** + * Ref and documentation: https://github.com/damian-burke/danger-plugin-lint-report + * This file will check all the error in XML Checkstyle format. + * It covers, lint, ktlint, and detekt errors + */ + +const reporter = require("danger-plugin-lint-report") +schedule(reporter.scan({ + /** + * File mask used to find XML checkstyle reports. + */ + fileMask: "**/reports/**/**.xml", + /** + * If set to true, the severity will be used to switch between the different message formats (message, warn, fail). + */ + reportSeverity: true, + /** + * If set to true, only issues will be reported that are contained in the current changeset (line comparison). + * If set to false, all issues that are in modified files will be reported. + */ + requireLineModification: false, + /** + * Optional: Sets a prefix foreach violation message. + * This can be useful if there are multiple reports being parsed to make them distinguishable. + */ + // outputPrefix?: "" + + /** + * Optional: If set to true, it will remove duplicate violations. + */ + removeDuplicates: true, +})) diff --git a/tools/danger/dangerfile.js b/tools/danger/dangerfile.js new file mode 100644 index 0000000000..7270f334ae --- /dev/null +++ b/tools/danger/dangerfile.js @@ -0,0 +1,182 @@ +const {danger, warn} = require('danger') +const fs = require('fs') +const path = require('path') + +/** + * Note: if you update the checks in this file, please also update the file ./docs/danger.md + */ + +// Useful to see what we got in danger object +// warn(JSON.stringify(danger)) + +const pr = danger.github.pr +const github = danger.github +// User who has created the PR. +const user = pr.user.login +const modified = danger.git.modified_files +const created = danger.git.created_files +const editedFiles = [...modified, ...created] + +// Check that the PR has a description +if (pr.body.length == 0) { + warn("Please provide a description for this PR.") +} + +// Warn when there is a big PR +if (editedFiles.length > 50) { + message("This pull request seems relatively large. Please consider splitting it into multiple smaller ones.") +} + +// Request a changelog for each PR +const changelogAllowList = [ + "dependabot[bot]", +] + +const requiresChangelog = !changelogAllowList.includes(user) + +if (requiresChangelog) { + const changelogFiles = editedFiles.filter(file => file.startsWith("changelog.d/")) + + if (changelogFiles.length == 0) { + warn("Please add a changelog. See instructions [here](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog)") + } else { + const validTowncrierExtensions = [ + "bugfix", + "doc", + "feature", + "misc", + "wip", + ] + if (!changelogFiles.every(file => validTowncrierExtensions.includes(file.split(".").pop()))) { + fail("Invalid extension for changelog. See instructions [here](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#changelog)") + } + } +} + +// check that frozen classes have not been modified +const frozenClasses = [ +] + +frozenClasses.forEach(frozen => { + if (editedFiles.some(file => file.endsWith(frozen))) { + fail("Frozen class `" + frozen + "` has been modified. Please do not modify frozen class.") + } + } +) + +// Check for a sign-off +const signOff = "Signed-off-by:" + +// Please add new names following the alphabetical order. +const allowList = [ + "aringenbach", + "BillCarsonFr", + "bmarty", + "csmith", + "dependabot[bot]", + "Florian14", + "ganfra", + "github-actions[bot]", + "jmartinesp", + "jonnyandrew", + "julioromano", + "kittykat", + "langleyd", + "MadLittleMods", + "manuroe", + "renovate[bot]", + "stefanceriu", + "yostyle", +] + +function signoff_needed(reason) { + message("Sign-off required, " + reason) + const hasPRBodySignOff = pr.body.includes(signOff) + const hasCommitSignOff = danger.git.commits.every(commit => commit.message.includes(signOff)) + if (!hasPRBodySignOff && !hasCommitSignOff) { + fail("Please add a sign-off to either the PR description or to the commits themselves. See instructions [here](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off).") + } +} + +function signoff_unneeded(reason) { + message("Sign-off not required, " + reason) +} + +// Somewhat awkward phrasing, dangerfile is not in an async context. +if (allowList.includes(user)) { + signoff_unneeded("allow-list") +} else { +// github.api.rest.orgs.checkMembershipForUser({ +// org: "vector-im", +// username: user, +// }).then((result) => { + github.api.rest.teams.getMembershipForUserInOrg({ + org: "vector-im", + team_slug: "vector-core", + username: user, + }).then((result) => { + if (result.status == 204 || result.status == 200) { + signoff_unneeded("team-member") + } + else { + signoff_needed("not-team-member") + } + }).catch((error) => { + if (error.response.status == 404) { + signoff_needed("not-team-member"); + } else { + console.log(error); signoff_needed("error") + } + }) +} + +const previewAnnotations = [ + 'androidx.compose.ui.tooling.preview.Preview', + 'io.element.android.libraries.designsystem.preview.LargeHeightPreview', + 'io.element.android.libraries.designsystem.preview.DayNightPreviews' +] + +const filesWithPreviews = editedFiles.filter(file => file.endsWith(".kt")).filter(file => { + const content = fs.readFileSync(file); + return previewAnnotations.some((ann) => content.includes(ann)); +}) + +const buildFilesWithMissingProcessor = filesWithPreviews.map(file => { + let parent = path.dirname(file); + while (fs.statSync(path.join(parent, 'build.gradle.kts'), {throwIfNoEntry: false}) === undefined) { + parent = path.dirname(parent); + } + return path.join(parent, 'build.gradle.kts'); +}).filter((value, index, array) => array.indexOf(value) === index).filter(buildFile => { + const content = fs.readFileSync(buildFile); + return !content.includes('ksp(libs.showkase.processor)'); +}) + +if (buildFilesWithMissingProcessor.length > 0) { + warn("You have made changes to a file containing a `@Preview` annotated function but its module doesn't include the showkase processor. Missing processor in: " + buildFilesWithMissingProcessor.join(", ")) +} + +// Check for pngs on resources +const hasPngs = editedFiles.filter(file => { + file.toLowerCase().endsWith(".png") && !file.includes("snapshots/images/") // Exclude screenshots +}).length > 0 +if (hasPngs) { + warn("You seem to have made changes to some images. Please consider using an vector drawable.") +} + +// Check that translations have not been modified by developers +const translationAllowList = [ + "RiotTranslateBot", + "github-actions[bot]", +] + +if (!translationAllowList.includes(user)) { + if (editedFiles.some(file => file.endsWith("strings.xml") && !file.endsWith("values/strings.xml"))) { + fail("Some translation files have been edited. Only user `RiotTranslateBot` (i.e. translations coming from Weblate) or `github-actions[bot]` (i.e. translations coming from automation) are allowed to do that.\nPlease read more about translations management [in the doc](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#internationalisation).") + } + + // Check that new strings are not added to `values/strings.xml` + if (editedFiles.some(file => file.endsWith("ui-strings/src/main/res/values/strings.xml"))) { + fail("`ui-strings/src/main/res/values/strings.xml` has been edited. This file will be overridden in the next strings synchronisation. Please add new strings in the file `values/strings_eax.xml` instead.") + } +} diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml new file mode 100644 index 0000000000..a3bad54ab3 --- /dev/null +++ b/tools/detekt/detekt.yml @@ -0,0 +1,157 @@ +# Default rules: https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml + +style: + MaxLineLength: + # Default is 120 + maxLineLength: 160 + MagicNumber: + active: false + ReturnCount: + active: false + UnnecessaryAbstractClass: + active: false + FunctionOnlyReturningConstant: + active: false + UnusedPrivateMember: + # TODO Enable it + active: false + UnusedParameter: + # TODO Enable it + active: false + UnusedPrivateProperty: + # TODO Enable it + active: false + ThrowsCount: + active: false + LoopWithTooManyJumpStatements: + active: false + SerialVersionUIDInSerializableClass: + active: false + ProtectedMemberInFinalClass: + active: false + UseCheckOrError: + active: false + +empty-blocks: + EmptyFunctionBlock: + active: false + EmptySecondaryConstructor: + active: false + +potential-bugs: + ImplicitDefaultLocale: + active: false + +exceptions: + TooGenericExceptionCaught: + active: false + SwallowedException: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + TooGenericExceptionThrown: + active: false + InstanceOfCheckForException: + active: false + +complexity: + TooManyFunctions: + active: false + LongMethod: + active: false + LongParameterList: + active: false + CyclomaticComplexMethod: + active: false + NestedBlockDepth: + active: false + ComplexCondition: + active: false + LargeClass: + active: false + +naming: + VariableNaming: + # TODO Enable it + active: false + TopLevelPropertyNaming: + # TODO Enable it + active: false + FunctionNaming: + active: true + ignoreAnnotated: ['Composable'] + +performance: + SpreadOperator: + active: false + +# Note: all rules for `comments` are disabled by default, but I put them here to be aware of their existence +comments: + AbsentOrWrongFileLicense: + active: true + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: true + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: true + EndOfSentenceFormat: + active: true + OutdatedDocumentation: + active: true + allowParamOnConstructorProperties: true + UndocumentedPublicClass: + active: false + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +Compose: + CompositionLocalAllowlist: + active: true + # You can optionally define a list of CompositionLocals that are allowed here + allowedCompositionLocals: LocalCompoundColors, LocalSnackbarDispatcher, LocalCameraPositionState + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + # You can optionally add your own composables here + # contentEmitters: MyComposable,MyOtherComposable + ModifierComposable: + active: true + ModifierMissing: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + # You can optionally add your own composables here + # contentEmitters: MyComposable,MyOtherComposable + MutableParams: + active: true + ComposableNaming: + active: true + # You can optionally disable the checks in this rule for regex matches against the composable name (e.g. molecule presenters) + # allowedComposableFunctionNames: .*Presenter,.*MoleculePresenter + ComposableParamOrder: + active: true + PreviewNaming: + active: true + PreviewPublic: + active: false + # You can optionally disable that only previews with @PreviewParameter are flagged + previewPublicOnlyIfParams: false + RememberMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + ## TODO Set to true later + active: false + ViewModelInjection: + active: true diff --git a/tools/detekt/license.template b/tools/detekt/license.template new file mode 100644 index 0000000000..08cadc82f9 --- /dev/null +++ b/tools/detekt/license.template @@ -0,0 +1,15 @@ +\/\* +(?:.*\n)* \* Copyright \(c\) 20\d\d New Vector Ltd +(?:.*\n)* \* + \* 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(?:s)?:\/\/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\. + \*\/ diff --git a/tools/docs/generateModuleGraph.sh b/tools/docs/generateModuleGraph.sh new file mode 100755 index 0000000000..2ce7d5b26b --- /dev/null +++ b/tools/docs/generateModuleGraph.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# +# Copyright (c) 2022 New Vector Ltd +# +# 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. +# + +## Dependency graph https://github.com/savvasdalkitsis/module-dependency-graph +dotPath=`pwd`/docs/images/module_graph.dot +pngPath=`pwd`/docs/images/module_graph.png +./gradlew graphModules -PdotFilePath=${dotPath} -PgraphOutputFilePath=${pngPath} -PautoOpenGraph=false +rm ${dotPath} diff --git a/tools/git/validate_lfs.sh b/tools/git/validate_lfs.sh new file mode 100755 index 0000000000..ce121057b6 --- /dev/null +++ b/tools/git/validate_lfs.sh @@ -0,0 +1,29 @@ +#! /bin/bash + +# +# Copyright (c) 2022 New Vector Ltd +# +# 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. +# +# Based on https://cashapp.github.io/paparazzi/#git-lfs + +# Compare the output of `git ls-files ':(attr:filter=lfs)'` against `git lfs ls-files` +# If there's no diff we assume the files have been committed using git lfs +diff <(git ls-files ':(attr:filter=lfs)' | sort) <(git lfs ls-files -n | sort) >/dev/null + +ret=$? +if [[ $ret -ne 0 ]]; then + echo >&2 "Detected files committed without using Git LFS." + echo >&2 "Install git lfs (eg brew install git-lfs) and run 'git lfs install --local' within the root repository directory and re-commit your files." + exit 1 +fi diff --git a/tools/lint/lint.xml b/tools/lint/lint.xml new file mode 100644 index 0000000000..914c9e7b68 --- /dev/null +++ b/tools/lint/lint.xml @@ -0,0 +1,138 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ Copyright (c) 2022 New Vector Ltd + ~ + ~ 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. + --> + +<lint> + + <!-- Ensure this file does not contain unknown Ids --> + <issue id="UnknownIssueId" severity="warning" /> + + <!-- Modify some severity --> + + <!-- Resource --> + <issue id="MissingTranslation" severity="ignore" /> + <issue id="TypographyEllipsis" severity="error" /> + <issue id="ImpliedQuantity" severity="warning" /> + <issue id="MissingQuantity" severity="warning" /> + <issue id="UnusedQuantity" severity="error" /> + <issue id="IconXmlAndPng" severity="error" /> + <issue id="IconDipSize" severity="error" /> + <issue id="IconDuplicatesConfig" severity="error" /> + <issue id="IconDuplicates" severity="error" /> + <issue id="IconExpectedSize" severity="error" /> + <issue id="LocaleFolder" severity="error" /> + + <!-- AlwaysShowAction is considered as an error to force ignoring the issue when detected --> + <issue id="AlwaysShowAction" severity="error" /> + + <issue id="TooManyViews" severity="warning"> + <!-- Ignore TooManyViews in debug build type --> + <ignore path="**/src/debug/**" /> + </issue> + + <issue id="UnusedResources" severity="warning"> + <!-- Ignore unused strings resource from localazy --> + <ignore path="**/localazy.xml" /> + <!-- Ignore unused resource in debug build type --> + <ignore path="**/src/debug/**" /> + + <!-- Following resources are for F-Droid variant only, so ignore for GPlay --> + <ignore regexp="settings_troubleshoot_test_service_boot_*" /> + <ignore regexp="settings_troubleshoot_test_bg_restricted_*" /> + <ignore regexp="settings_troubleshoot_test_battery_*" /> + + <!-- Following resources are for GPlay variant only, so ignore for F-Droid --> + <ignore regexp="settings_troubleshoot_test_play_services_" /> + <ignore regexp="settings_troubleshoot_test_push_loop_" /> + <ignore regexp="settings_troubleshoot_test_token_registration_" /> + <ignore regexp="settings_troubleshoot_test_fcm_" /> + <ignore regexp="no_valid_google_play_services_apk" /> + <ignore regexp="sas_error_unknown" /> + </issue> + + <!-- UX --> + <issue id="ButtonOrder" severity="error" /> + <issue id="TextFields" severity="error" /> + + <!-- Accessibility --> + <issue id="LabelFor" severity="error" /> + <issue id="ContentDescription" severity="error" /> + <issue id="SpUsage" severity="error" /> + + <!-- Layout --> + <issue id="UnknownIdInLayout" severity="error" /> + <issue id="StringFormatCount" severity="error" /> + <issue id="HardcodedText" severity="error" /> + <issue id="ObsoleteLayoutParam" severity="error" /> + <issue id="InefficientWeight" severity="error" /> + <issue id="DisableBaselineAlignment" severity="error" /> + <issue id="ScrollViewSize" severity="error" /> + <issue id="NegativeMargin" severity="error" /> + <issue id="UseCompatTextViewDrawableXml" severity="error" /> + + <!-- RTL --> + <issue id="RtlEnabled" severity="error" /> + <issue id="RtlHardcoded" severity="error" /> + <issue id="RtlSymmetry" severity="error" /> + + <!-- Code --> + <issue id="NewApi" severity="error" /> + <issue id="SetTextI18n" severity="error" /> + <issue id="ViewConstructor" severity="error" /> + <issue id="UseValueOf" severity="error" /> + <issue id="ObsoleteSdkInt" severity="error" /> + <issue id="Recycle" severity="error" /> + <issue id="KotlinPropertyAccess" severity="error" /> + <issue id="DefaultLocale" severity="error" /> + <issue id="CheckResult" severity="error" /> + <issue id="StaticFieldLeak" severity="error" /> + + <issue id="InvalidPackage"> + <!-- Ignore error from HtmlCompressor lib --> + <ignore path="**/htmlcompressor-1.4.jar" /> + <!-- Ignore error from dropbox-core-sdk-3.0.8 lib, which comes with Jitsi library --> + <ignore path="**/dropbox-core-sdk-3.0.8.jar" /> + </issue> + + <!-- Manifest --> + <issue id="PermissionImpliesUnsupportedChromeOsHardware" severity="error" /> + <issue id="DataExtractionRules" severity="error" /> + + <!-- Performance --> + <issue id="UselessParent" severity="error" /> + + <!-- Dependencies --> + <issue id="KtxExtensionAvailable" severity="error" /> + + <!-- Timber --> + <!-- This rule is failing on CI because it's marked as unknwown rule id :/--> + <!-- <issue id="BinaryOperationInTimber" severity="error" />--> + <issue id="LogNotTimber" severity="error" /> + + <!-- Wording --> + <issue id="Typos" severity="error" /> + <issue id="TypographyDashes" severity="error" /> + <issue id="PluralsCandidate" severity="error" /> + + <!-- Notification --> + <issue id="LaunchActivityFromNotification" severity="error" /> + + <!-- We handle them manually --> + <issue id="EnsureInitializerMetadata" severity="ignore" /> + + <!-- DI --> + <!-- issue id="JvmStaticProvidesInObjectDetector" severity="error" /--> +</lint> diff --git a/tools/localazy/README.md b/tools/localazy/README.md new file mode 100644 index 0000000000..c1ad1d0ee0 --- /dev/null +++ b/tools/localazy/README.md @@ -0,0 +1,72 @@ +# Localazy + +Localazy is used to host the source strings and their translations. + +<!--- TOC --> + +* [Localazy project](#localazy-project) + * [Key naming rules](#key-naming-rules) + * [Special suffixes](#special-suffixes) + * [Placeholders](#placeholders) +* [CLI Installation](#cli-installation) +* [Download translations](#download-translations) +* [Add translations to a specific module](#add-translations-to-a-specific-module) + +<!--- END --> + +## Localazy project + +To add new strings, or to translate existing strings, go the the Localazy project: [https://localazy.com/p/element](https://localazy.com/p/element). Please follow the key naming rules (see below). + +Never edit manually the files `localazy.xml` or `translations.xml`!. + +### Key naming rules + +For code clarity and in order to download strings to the correct module, here are some naming rules to follow as much as possible: + +- Keys for common strings, i.e. strings that can be used at multiple places must start by `action_` if this is a verb, or `common_` if not; +- Keys for common accessibility strings must start by `a11y_`. Example: `a11y_hide_password`; +- Keys for strings used in a single screen must start with `screen_` followed by the screen name, followed by a free name. Example: `screen_onboarding_welcome_title`; +- Keys can have `_title` or `_subtitle` suffixes. Example: `screen_onboarding_welcome_title`, `screen_change_server_subtitle`; +- For dialogs, keys can have `_dialog_title`, `_dialog_content`, and `_dialog_submit` suffixes. Example: `screen_signout_confirmation_dialog_title`, `screen_signout_confirmation_dialog_content`, `screen_signout_confirmation_dialog_submit`; +- `a11y_` pattern can be used for strings that are only used for accessibility. Example: `a11y_hide_password`, `screen_roomlist_a11y_create_message`; +- Strings for error message can start by `error_`, or contain `_error_` if used in a specific screen only. Example: `error_some_messages_have_not_been_sent`, `screen_change_server_error_invalid_homeserver`. + +*Note*: those rules applies for `strings` and for `plurals`. + +#### Special suffixes + +- if a key is suffixed by `_ios`, it will not be imported in the Android project; +- if a key is suffixed by `_android`, it will not be imported in the iOS project. + +So feel free to use those suffixes when necessary for instance when the string content is referring to something related to Android only, or iOS only. + +#### Placeholders + +Placeholders should have the form `%1$s`, `%1$d`, etc.. Please use numbered placeholders. Note that Localazy will take care of converting the placeholder to Android (-> `%1$s`) and iOS specific format (-> `%1$@`). Ideally add a comment on Localazy to explain with what the placeholder(s) will be replaced at runtime. + +## CLI Installation + +To install the Localazy client, follow the instructions from [here](https://localazy.com/docs/cli/installation). + +## Download translations + +In the root folder of the project, run: + +```shell +./tools/localazy/downloadStrings.sh +``` + +It will update all the `localazy.xml` resource files. In case of merge conflicts, just erase the files and download again using the script. + +To also include the translations, i.e. the `translations.xml` files, add `--all` argument: + +```shell +./tools/localazy/downloadStrings.sh --all +``` + +## Add translations to a specific module + +Edit the file [config.json](./config.json) to add a new module, or add a new item in `includeRegex` arrays, then run the script again to see the effect. + +[generateLocalazyConfig.py](generateLocalazyConfig.py) is the Python script that convert `config.json` to a localazy configuration file. Generally you should not edit this file. diff --git a/tools/localazy/config.json b/tools/localazy/config.json new file mode 100644 index 0000000000..fce6b317b5 --- /dev/null +++ b/tools/localazy/config.json @@ -0,0 +1,124 @@ +{ + "modules": [ + { + "name": ":features:rageshake:impl", + "includeRegex": [ + "screen_bug_report_.*" + ] + }, + { + "name": ":features:rageshake:api", + "includeRegex": [ + "crash_detection_.*", + "rageshake_detection_.*" + ] + }, + { + "name": ":features:logout:api", + "includeRegex": [ + "screen_signout_.*" + ] + }, + { + "name": ":features:onboarding:impl", + "includeRegex": [ + "screen_onboarding_.*" + ] + }, + { + "name": ":features:invitelist:impl", + "includeRegex": [ + "screen_invites_.*" + ] + }, + { + "name": ":features:createroom:impl", + "includeRegex": [ + "screen_create_room_.*", + "screen_start_chat_.*" + ] + }, + { + "name": ":features:verifysession:impl", + "includeRegex": [ + "screen_session_verification_.*" + ] + }, + { + "name": ":libraries:textcomposer", + "includeRegex": [ + "rich_text_editor_.*" + ] + }, + { + "name": ":libraries:androidutils", + "includeRegex": [ + "error_no_compatible_app_found" + ] + }, + { + "name": ":libraries:eventformatter:impl", + "includeRegex": [ + "state_event_.*" + ] + }, + { + "name": ":libraries:push:impl", + "includeRegex": [ + "push_.*", + "notification_.*" + ] + }, + { + "name": ":features:login:impl", + "includeRegex": [ + "screen_login_.*", + "screen_server_confirmation_.*", + "screen_change_server_.*", + "screen_change_account_provider_.*", + "screen_account_provider_.*", + "screen_waitlist_.*" + ] + }, + { + "name": ":features:roomlist:impl", + "includeRegex": [ + "screen_roomlist_.*", + "session_verification_banner_.*" + ] + }, + { + "name": ":features:roomdetails:impl", + "includeRegex": [ + "screen_room_details_.*", + "screen_room_member_list_.*", + "screen_dm_details_.*" + ] + }, + { + "name": ":features:messages:impl", + "includeRegex": [ + "screen_room_.*", + "screen_dm_details_.*", + "room_timeline_state_changes" + ], + "excludeRegex": [ + "screen_room_details_.*", + "screen_room_member.*", + "screen_dm_.*" + ] + }, + { + "name": ":features:analytics:impl", + "includeRegex": [ + "screen_analytics_prompt.*" + ] + }, + { + "name": ":features:ftue:impl", + "includeRegex": [ + "screen_welcome_.*" + ] + } + ] +} diff --git a/tools/localazy/downloadStrings.sh b/tools/localazy/downloadStrings.sh new file mode 100755 index 0000000000..bc00488258 --- /dev/null +++ b/tools/localazy/downloadStrings.sh @@ -0,0 +1,52 @@ +#! /bin/bash + +# +# Copyright (c) 2023 New Vector Ltd +# +# 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. +# + +set -e + +if [[ $1 == "--all" ]]; then + echo "Note: I will update all the files." + allFiles=1 +else + echo "Note: I will update only the English files." + allFiles=0 +fi + +echo "Generating the configuration file for localazy..." +python3 ./tools/localazy/generateLocalazyConfig.py $allFiles + +echo "Deleting all existing localazy.xml files..." +find . -name 'localazy.xml' -delete + +if [[ $allFiles == 1 ]]; then + echo "Deleting all existing translations.xml files..." + find . -name 'translations.xml' -delete +fi + +echo "Importing the strings..." +localazy download --config ./tools/localazy/localazy.json + +echo "Add new lines to the end of the files..." +find . -name 'localazy.xml' -print0 -exec bash -c "echo \"\" >> \"{}\"" \; >> /dev/null +if [[ $allFiles == 1 ]]; then + find . -name 'translations.xml' -print0 -exec bash -c "echo \"\" >> \"{}\"" \; >> /dev/null +fi + +echo "Removing the generated config" +rm ./tools/localazy/localazy.json + +echo "Success!" diff --git a/tools/localazy/generateLocalazyConfig.py b/tools/localazy/generateLocalazyConfig.py new file mode 100755 index 0000000000..6dfffa9c69 --- /dev/null +++ b/tools/localazy/generateLocalazyConfig.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import json +import sys + +# Read the config.json file +with open('./tools/localazy/config.json', 'r') as f: + config = json.load(f) + +allFiles = sys.argv[1] == "1" + +# Convert a module name to a path +# Ex: ":features:verifysession:impl" => "features/verifysession/impl" +def convertModuleToPath(name): + return name[1:].replace(":", "/") + +# Regex that will be excluded from the Android project, you may add items here if necessary. +regexToAlwaysExclude = [ + "Notification", + ".*_ios" +] + +baseAction = { + "type": "android", + # Replacement done in all string values + "replacements": { + "...": "…" + } +} + +# Store all regex specific to module, to eclude the corresponding keyx from the common string module +allRegexToExcludeFromMainModule = [] +# All actions that will be serialized in the localazy config +allActions = [] + +# Iterating on the config +for entry in config["modules"]: + # Create action for the default language + excludeRegex = regexToAlwaysExclude + if "excludeRegex" in entry: + excludeRegex += entry["excludeRegex"] + action = baseAction | { + "output": convertModuleToPath(entry["name"]) + "/src/main/res/values/localazy.xml", + "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])), + "excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)), + "conditions": [ + "equals: ${languageCode}, en" + ] + } + # print(action) + allActions.append(action) + # Create action for the translations + if allFiles: + actionTranslation = baseAction | { + "output": convertModuleToPath(entry["name"]) + "/src/main/res/values-${langAndroidResNoScript}/translations.xml", + "includeKeys": list(map(lambda i: "REGEX:" + i, entry["includeRegex"])), + "excludeKeys": list(map(lambda i: "REGEX:" + i, excludeRegex)), + "conditions": [ + "!equals: ${languageCode}, en" + ] + } + allActions.append(actionTranslation) + allRegexToExcludeFromMainModule.extend(entry["includeRegex"]) + +# Append configuration for the main string module: default language +mainAction = baseAction | { + "output": "libraries/ui-strings/src/main/res/values/localazy.xml", + "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), + "conditions": [ + "equals: ${languageCode}, en" + ] +} +# print(mainAction) +allActions.append(mainAction) + +if allFiles: + # Append configuration for the main string module: translations + mainActionTranslation = baseAction | { + "output": "libraries/ui-strings/src/main/res/values-${langAndroidResNoScript}/translations.xml", + "excludeKeys": list(map(lambda i: "REGEX:" + i, allRegexToExcludeFromMainModule + regexToAlwaysExclude)), + "conditions": [ + "!equals: ${languageCode}, en" + ] + } + allActions.append(mainActionTranslation) + +# Generate the configuration for localazy +result = { + "readKey": "a7876306080832595063-aa37154bb3772f6146890fca868d155b2228b492c56c91f67abdcdfb74d6142d", + "conversion": { + "actions": allActions + } +} + +# Json serialization +with open('./tools/localazy/localazy.json', 'w') as json_file: + json.dump(result, json_file, indent=4, sort_keys=True) diff --git a/tools/templates/FeatureModule.json b/tools/templates/FeatureModule.json new file mode 100644 index 0000000000..4ad5e3a676 --- /dev/null +++ b/tools/templates/FeatureModule.json @@ -0,0 +1 @@ +{"template":{"name":"","isDir":true,"placeholders":{"MODULE_NAME":"","FEATURE_NAME":"","BUILD_GRADLE_API":"build.gradle.kts","BUILD_GRADLE_IMPL":"build.gradle.kts"},"fileTemplates":{"${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point API","Default${FEATURE_NAME}EntryPoint":"Template Module Feature Entry Point Flow Impl","${BUILD_GRADLE_API}":"Template Module Feature Build Gradle API","${BUILD_GRADLE_IMPL}":"Template Module Feature Build Gradle Impl","${FEATURE_NAME}FlowNode":"Template Module Feature Node Flow Impl"},"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"api","isDir":true,"realChildren":[{"name":"${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_API}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]},{"name":"impl","isDir":true,"realChildren":[{"name":"src","isDir":true,"realChildren":[{"name":"main","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[{"name":"Default${FEATURE_NAME}EntryPoint","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]},{"name":"${FEATURE_NAME}FlowNode","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"test","isDir":true,"realChildren":[{"name":"kotlin","isDir":true,"realChildren":[{"name":"io","isDir":true,"realChildren":[{"name":"element","isDir":true,"realChildren":[{"name":"android","isDir":true,"realChildren":[{"name":"features","isDir":true,"realChildren":[{"name":"${MODULE_NAME}","isDir":true,"realChildren":[{"name":"impl","isDir":true,"realChildren":[]}]}]}]}]}]}]}]}]},{"name":"${BUILD_GRADLE_IMPL}","isDir":false,"placeholders":{},"fileTemplates":{},"realChildren":[]}]}]}]},"language":"java","templateName":"FeatureModule","lowercaseDir":true,"capitalizeFile":false,"packageNameToDir":false} \ No newline at end of file diff --git a/tools/templates/file_templates.zip b/tools/templates/file_templates.zip new file mode 100644 index 0000000000..7352ac3074 Binary files /dev/null and b/tools/templates/file_templates.zip differ diff --git a/tools/towncrier/template.md b/tools/towncrier/template.md new file mode 100644 index 0000000000..4e5e96a4ac --- /dev/null +++ b/tools/towncrier/template.md @@ -0,0 +1,36 @@ +{% if top_line %} +{{ top_line }} +{{ top_underline * ((top_line)|length)}} +{% elif versiondata.name %} +{{ versiondata.name }} {{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.name + versiondata.version + versiondata.date)|length + 4)}} +{% else %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{% endif %} +{% for section, _ in sections.items() %} +{% set underline = underlines[0] %}{% if section %}{{section}} +{{ underline * section|length }}{% set underline = underlines[1] %} +{% endif %} + +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section]%} +{{ definitions[category]['name'] }} +{{ underline * definitions[category]['name']|length }} +{% if definitions[category]['showcontent'] %} +{% for text, values in sections[section][category].items() %} + - {{ text }} ({{ values|join(', ') }}) +{% endfor %} +{% else %} + - {{ sections[section][category]['']|join(', ') }} +{% endif %} +{% if sections[section][category]|length == 0 %} +No significant changes. +{% else %} +{% endif %} + +{% endfor %} +{% else %} +No significant changes. +{% endif %} +{% endfor %} diff --git a/towncrier.toml b/towncrier.toml new file mode 100644 index 0000000000..c9be3af199 --- /dev/null +++ b/towncrier.toml @@ -0,0 +1,31 @@ +[tool.towncrier] + directory = "changelog.d" + filename = "CHANGES.md" + name = "Changes in Element X" + template = "tools/towncrier/template.md" + issue_format = "[#{issue}](https://github.com/vector-im/element-x-android/issues/{issue})" + + [[tool.towncrier.type]] + directory = "feature" + name = "Features ✨" + showcontent = true + + [[tool.towncrier.type]] + directory = "bugfix" + name = "Bugfixes 🐛" + showcontent = true + + [[tool.towncrier.type]] + directory = "wip" + name = "In development 🚧" + showcontent = true + + [[tool.towncrier.type]] + directory = "doc" + name = "Improved Documentation 📚" + showcontent = true + + [[tool.towncrier.type]] + directory = "misc" + name = "Other changes" + showcontent = true